Skip to content

Record streams the caller abandons - #243

Merged
adamjohnwright merged 3 commits into
mainfrom
feat/log-abandoned-streams
Sep 18, 2026
Merged

adamjohnwright merged 3 commits into
mainfrom
feat/log-abandoned-streams

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

Prompted by the website session finding a bug on their side: a keystroke unmounted their panel mid-answer and left our stream running. From here that looked like ordinary traffic — and they were right that we would have gone looking in our own code.

What was missing

Cancellation already worked. A hang-up stops the graph and the model call, which is why their proxy needs to do nothing beyond closing the connection. What was missing was any record of it: an abandoned stream is indistinguishable from a healthy one in every other signal, because the request 200s, tokens flow, and then nothing further happens.

Now:

answer abandoned by the caller after 1.5s and 3 token events

A rate of these is the symptom of a caller starting answers it does not want.

The test found a second mechanism

A hang-up arrives as either exception depending on who notices first:

how raises
Starlette cancels the task asyncio.CancelledError
the response generator is closed GeneratorExit

A handler catching only CancelledError passed the live hang-up and failed the generator-close test. Both are caught now, and both are re-raised — cancellation is not an error to report to a caller who has already gone, and swallowing it would leave the task pretending to run.

Verified both ways: the unit test closes the response iterator mid-stream; a live HTTP hang-up produced the log line above with exactly 3 events produced server-side for 3 read.

Audit, from their generalisation

They observed that both bugs found today were the same shape — a second caller kept doing the wrong thing after the first was fixed. That is exactly the endpoint-then-sweep postprocess defect, so I checked every caller of the graph rather than assuming two was all of them:

caller postprocess
src/api/answer.py False
src/evaluation/answer_sweep.py False (#242)
bin/chat-chainlit.py feature flag, and it renders the results

No fourth. The remaining ainvoke calls are inner chains that never reach the postprocess node.

CI-equivalent locally: ruff, format, mypy (129 files), full suite with no API keys set.

🤖 Generated with Claude Code

adamjohnwright and others added 3 commits September 18, 2026 05:03
Cancellation already worked: a hang-up stops the graph and the model call, which
is why the website's proxy needs to do nothing beyond closing the connection.
What was missing was any trace of it. An abandoned stream is indistinguishable
from a healthy one in every other signal -- the request 200s, tokens flow, and
then nothing further happens.

The website found a bug where a keystroke unmounted their panel mid-answer and
left our stream running for up to the full ceiling. From this side that was
ordinary traffic, and they were right that we would have looked for the cause in
our own code. A rate of these is the symptom of a caller starting answers it does
not want.

Both forms are caught, and the second was found by the test failing: Starlette
cancelling the task raises CancelledError, while closing the generator raises
GeneratorExit. A handler catching only CancelledError passed the live hang-up and
failed the generator close.

Re-raised, never swallowed. Cancellation is not an error to report to a caller
who has already gone, and suppressing it would leave the task pretending to run.

Verified both ways: the unit test closes the response iterator mid-stream, and a
live hang-up over HTTP logged "answer abandoned by the caller after 1.5s and 3
token events" with exactly 3 events produced server-side for 3 read.

Also audited every caller of the graph for the postprocess defect, since that is
the shape both repos hit today -- a second caller kept doing the wrong thing
after the first was fixed. Three drive the graph: the endpoint and the sweep pass
enable_postprocess=False, and the chat UI passes its feature flag and renders the
results. No fourth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pushed the previous commit with mypy failing: I read its output and not its
exit code, which is the mistake the pipe-to-tail pattern invites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit 230a15c into main Sep 18, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the feat/log-abandoned-streams branch September 18, 2026 05:09
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.

1 participant