Skip to content

fix(server): report a failing end-of-stream terminator instead of swallowing it - #137

Merged
cdeust merged 1 commit into
mainfrom
fix/issue135-silent-terminator
Aug 10, 2026
Merged

fix(server): report a failing end-of-stream terminator instead of swallowing it#137
cdeust merged 1 commit into
mainfrom
fix/issue135-silent-terminator

Conversation

@cdeust

@cdeust cdeust commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #135 — a separate defect from #134, and one that survives it.
graph_build_run.py's finally block sends the single end-of-stream
close() call inside except Exception: pass. Any failure there is
completely invisible: no log line, no counter. That silence is exactly why
#134's AttributeError on this same call (close() didn't exist after
45d4a80) survived a release unnoticed — #134 stops that failure, not the
next one this handler can still hide.

The existing comment's reasoning is half right: a failure here must not
mask the build outcome being unwound, so it must not be re-raised. But
not-masking and not-reporting are different things.

What changed

except Exception: pass → the exception's type and message are printed to
stderr before the lock releases. Still never re-raised.

Tests (tests/test_graph_build_run_terminator.py)

  • run_build's first statement is forced to fail (no store/DB dependency
    needed to reach finally).
  • close() raising is reported on stderr with the exception named, without
    re-raising and without leaking the build lock. Verified failing against
    the pre-fix except Exception: pass handler.
  • The nominal path (close() succeeds) stays quiet — asserted, not assumed.

Verification

  • uv run pytest tests/ -k "graph_build or graph_event or activity_stream or http_live" — 74 passed, 2 skipped.
  • uv run ruff check / ruff format --check — clean.

Co-Authored-By: Claude noreply@anthropic.com

…llowing it

graph_build_run.py's finally block sends the single end-of-stream
close() call inside `except Exception: pass`. Any failure there was
completely invisible -- no log line, no counter -- which is exactly
why #134's AttributeError on this same call (close() didn't exist
after 45d4a80) survived a release unnoticed. #134 makes close()
resolve again; this fixes the next failure mode, not just this one.

The comment's reasoning was half right: a failure here must not mask
the build outcome being unwound, so it is still never re-raised. But
not-masking and not-reporting are different things -- the exception is
now printed to stderr with its type and message before the lock
releases.

tests/test_graph_build_run_terminator.py forces run_build's first
statement to fail (no store/DB dependency needed to reach `finally`),
then covers both arms: close() raising is reported on stderr without
re-raising and without leaking the lock, and the nominal path (close()
succeeds) stays quiet. Verified failing against the pre-fix
`except Exception: pass` handler.

Fixes #135

Co-Authored-By: Claude <noreply@anthropic.com>
@cdeust

cdeust commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: APPROVE

This is the defect that let the other one reach a release, and it is correctly treated as separate rather than folded into it. #136 makes close() resolve again, which stops this failure and not the next one: the handler was written so that any failure to terminate the stream is invisible — no log line, no counter, nothing.

The original comment's reasoning was half right, and the fix keeps the half that was: a failure here must not mask the build outcome being unwound. But not-masking and not-reporting are different things. The terminator now prints the exception type and message to stderr without re-raising, so the unwind is preserved and the silence is not.

The consequence of the silence was real, not theoretical. Subscribers never received done, so they never disconnected and sat on an open SSE connection waiting for a terminator that was never sent — after a build that had spent fifteen minutes getting there. And at the level of the incident: the loud error was at line 155 and the second one at the bottom made no sound at all, which is how a build that could not start survived a release.

Both directions are tested, and the failing one was proven against the pre-fix code. One test forces run_build's first statement to fail so finally is reached without a database dependency, and asserts the failure is now reported and the lock still releases — verified failing against the original except Exception: pass by stashing back to it. The second asserts the nominal path stays quiet, which is what stops a reporting change from becoming noise that gets silenced again later.

ruff clean; targeted suite 74 passed / 2 skipped. All checks green, mergeable and clean.

Merging. With #136 already landed, the patch release follows.

@cdeust
cdeust merged commit dc8b81a into main Aug 10, 2026
12 checks passed
@cdeust
cdeust deleted the fix/issue135-silent-terminator branch August 10, 2026 16:34
cdeust added a commit that referenced this pull request Aug 10, 2026
…aces (#138)

3.1.0 could not build a graph at all: commit 45d4a80 deleted
graph_event_stream's module-level emit/close/reset forwarders on the
false premise of "no caller in this repository's history", killing
every build with AttributeError on the first statement (#134, fixed by
#136). #135/#137 stops the finally-block terminator from swallowing
that same failure silently, which is why #134 reached a release
unnoticed in the first place. #131 (mutation-test hardening, no
behavior change) and #132 (automates RELEASING.md step 5, MCP Registry
publish) also landed since 3.1.0 with no further breaking change, so
this is a patch release: 3.1.0 -> 3.1.1, not a restatement of 3.1.0.

Promotes CHANGELOG's Unreleased section (#132's entry) to 3.1.1,
carries forward #134/#135's fix descriptions, and reopens an empty
Unreleased. #131 gets no entry: it changes no shipped behavior. Aligns
every version-bearing surface pyproject.toml/cortex_viz/identity.py/
server.json/.claude-plugin/plugin.json/.codex-plugin/plugin.json/
gemini-extension.json/.claude-plugin/marketplace.json/uv.lock (via
`uv lock`) plus the two surfaces check_distribution_artifact does not
cover, the README badge and docs/ROADMAP.md's "current version" line.
Verified: `python -m scripts.check_distribution_artifact` passes
against a built wheel; full suite 1317 passed/10 skipped; ruff check
and format clean. Changelog-vs-tag audit: 3.1.0/2.8.0/2.7.1/2.7.0 all
have matching tags, 3.0.0's "cut in the tree, never tagged or
published" annotation is intact and unchanged, nothing new drifted.

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

the end-of-build stream terminator fails silently: close() inside except Exception: pass

1 participant