Skip to content

fix(llm): stop consuming the source iterable when the stream consumer cancels - #726

Merged
cevheri merged 4 commits into
libredb:mainfrom
Matthew-Selvam:fix/gemini-stream-cancel
Sep 17, 2026
Merged

cevheri merged 4 commits into
libredb:mainfrom
Matthew-Selvam:fix/gemini-stream-cancel

Conversation

@Matthew-Selvam

@Matthew-Selvam Matthew-Selvam commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Description

Per the review on this PR:

  1. The enqueue-throw path already stops the pull loop after cancellation on main — the claimed general defect was not there, and the isStreamCancelled guard was strictly harmful: a genuine TypeError whose message happens to mention "closed" gets swallowed, leaving the reader hung forever.
  2. The real defect is the filter path: when transform returns null for every item (Gemini's safety-blocked shape), nothing is enqueued, nothing throws, and the loop drains the entire SDK iterable after the consumer cancels — a full completion generated and billed for nobody.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Related Issue

Found during a code review of the LLM layer; no issue existed yet for it.

Changes Made

  • streamFromAsyncIterable records cancellation via the stream's cancel() callback and checks it at the top of every loop iteration, before transform/enqueue — the fix as requested in review, without the catch guard
  • Removed isStreamCancelled entirely; real errors keep flowing through controller.error()
  • Tests: the cancel test is now deterministic (generator suspended on resolvers, no sleeps) and asserts pulled === 2 for both the null-transform and enqueueing paths — it fails on main (pulled = 200); a regression test pins that genuine "closed" TypeErrors still reach the reader

Testing

  • I have tested this locally: format, lint, typecheck, knip, chart:check, channels:showcase:check, readme:check, security:check, build, build:lib + attw, streaming suite 30/30
  • I have added/updated tests
  • helm, 7z and node:sqlite are unavailable in this environment, so 181 unrelated tests (helm/packaging/sqlite-driver) fail identically with and without the patch — verified by running the suite on pristine HEAD before re-applying the change. Required checks run in CI.

… cancels

streamFromAsyncIterable kept pulling from the SDK iterable after the
consumer cancelled — generating (and billing) a full completion nobody
reads — until a post-cancel enqueue threw TypeError into the generic
catch and surfaced as a spurious stream error. Every aborted Gemini
completion took this path (the only consumer of the helper).

The pull loop now checks controller.desiredSize === null before each
transform/enqueue and exits quietly; a cancel racing the loop (the
enqueue/close throw) is recognized by isStreamCancelled and treated as a
normal exit. Real pipeline failures still error the stream.
…ch deterministically

Two CI failures in the new isStreamCancelled suite:

- The message regex used 'cancell' (double-L), which misses the American
  'canceled' spelling that runtimes actually emit — and that the test
  itself used. /cancel/ matches both.
- reader.cancel() does NOT null desiredSize in Bun's runtime (it stays 0),
  so the closed-state branch is now reached via controller.close(), which
  the spec defines as null. Also reformats the probe construction to
  satisfy Biome.
…nly null case

Per the WHATWG spec, desiredSize is null ONLY for an errored stream —
closed and cancelled streams keep a numeric desiredSize (Bun's runtime
confirmed: 0 after both close() and reader.cancel()). The in-loop
desiredSize === null bail-out was therefore unreachable in production:
this code only ever reaches the errored state through controller.error()
AFTER the check. Dead code, and a 100%-coverage-gate failure.

The cancel signal is the enqueue/close throw itself: a cancelled stream
makes the next enqueue throw TypeError, which isStreamCancelled now
recognizes. The direct unit test reaches the null branch via
controller.error() — the one state where the spec makes it null — and the
import line is split for Biome's line width.
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cevheri cevheri added the loop:needs-moderator-action Flagged by the maintainer loop: suspicious content or a decision only a human can make label Sep 9, 2026

@cevheri cevheri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I measured this one before replying, on Bun 1.4.2 and Node 24.14, and I do not think the defect is there.

After a consumer cancels, the loop already stops: the next enqueue throws, that throw ends the for await, and the generator is returned. With a 200-item iterable, pulled reaches 3 on main and 3 with this patch. Through a Response aborted mid-read, which is the production shape, it is the same 3 on both, with zero unhandled rejections. controller.error() on an already-cancelled stream is a spec no-op, so no spurious error reaches anyone either. The new "stops consuming" test passes unchanged against main, so it is not pinning the change.

The patch does change one thing, and it goes the wrong way. isStreamCancelled matches any TypeError whose message contains "cancel", "closed" or "invalid state". A genuine one, say Cannot read properties of undefined (reading 'closed'), now returns early and leaves the controller neither closed nor errored, so the reader never settles. Main rejects with the real error; with this patch that read hangs. Measured on both runtimes.

There is a real drain, though, and it is worth fixing. When transform returns null for every item, which is Gemini's safety-blocked path, nothing is enqueued, nothing throws, and the loop pulls all 200 items after cancel, on main and with this patch alike. The desiredSize === null check your description says the loop makes before each transform is not in the diff. That check, inside the loop, is the fix. I would take that on its own, without the catch guard.

@cevheri cevheri added the security Supply-chain, auth, or hardening work label Sep 9, 2026
@cevheri

cevheri commented Sep 13, 2026

Copy link
Copy Markdown
Member

@Matthew-Selvam friendly ping, are you still planning to work on this?

No rush, just want to know whether to keep it in the review queue.
If you'd rather not continue, that's completely fine, let me know and
I'll close it, or take it over myself since the fix is validated.

@cevheri

cevheri commented Sep 15, 2026

Copy link
Copy Markdown
Member

any update

1 similar comment
@cevheri

cevheri commented Sep 17, 2026

Copy link
Copy Markdown
Member

any update

@cevheri

cevheri commented Sep 17, 2026

Copy link
Copy Markdown
Member

closing

@cevheri cevheri closed this Sep 17, 2026
@Matthew-Selvam

Copy link
Copy Markdown
Contributor Author

forgot about this, sorry,do u want me to work on it?

@Matthew-Selvam

Matthew-Selvam commented Sep 17, 2026 via email

Copy link
Copy Markdown
Contributor Author

@cevheri cevheri reopened this Sep 17, 2026
@cevheri

cevheri commented Sep 17, 2026

Copy link
Copy Markdown
Member

it is yours

@cevheri cevheri removed the loop:needs-moderator-action Flagged by the maintainer loop: suspicious content or a decision only a human can make label Sep 17, 2026
Matthew-Selvam added a commit to Matthew-Selvam/libredb-studio that referenced this pull request Sep 17, 2026
Per review of libredb#726: the enqueue-throw path already stops the loop after
cancellation on main, and the isStreamCancelled guard was harmful — its
message-sniffing regex could swallow a genuine TypeError (e.g. reading
'closed') and leave the reader hung, while controller.error() on a
cancelled stream is a spec no-op anyway.

The real defect is the filter path: when transform returns null for
every item (Gemini's safety-blocked shape) nothing is ever enqueued, so
the loop drains the entire SDK iterable after the consumer cancels —
a full completion generated and billed for nobody. Fix it the way the
review asked: check for cancellation inside the loop, without the
catch guard.

- streamFromAsyncIterable flips a flag from the stream's cancel()
  callback and returns before each transform once cancelled
- drop isStreamCancelled entirely; real errors keep propagating
  through controller.error
- make the cancel test deterministic (suspend the generator instead of
  sleeping 50ms) and pin that genuine 'closed' TypeErrors still reach
  the reader
Per review of libredb#726: the enqueue-throw path already stops the loop after
cancellation on main, and the isStreamCancelled guard was harmful — its
message-sniffing regex could swallow a genuine TypeError (e.g. reading
'closed') and leave the reader hung, while controller.error() on a
cancelled stream is a spec no-op anyway.

The real defect is the filter path: when transform returns null for
every item (Gemini's safety-blocked shape) nothing is ever enqueued, so
the loop drains the entire SDK iterable after the consumer cancels —
a full completion generated and billed for nobody. Fix it the way the
review asked: check for cancellation inside the loop, without the
catch guard.

- streamFromAsyncIterable flips a flag from the stream's cancel()
  callback and returns before each transform once cancelled
- drop isStreamCancelled entirely; real errors keep propagating
  through controller.error
- make the cancel test deterministic (suspend the generator instead of
  sleeping 50ms) and pin that genuine 'closed' TypeErrors still reach
  the reader
@Matthew-Selvam
Matthew-Selvam force-pushed the fix/gemini-stream-cancel branch from ce467a3 to 309825f Compare September 17, 2026 15:46
@Matthew-Selvam

Matthew-Selvam commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

@cevheri Sorry for the silence — back on it now, and the branch is reworked per your review (309825f).

  • Dropped isStreamCancelled and the catch guard. You were right that it goes the wrong way: the "closed"-message TypeError case leaves the reader hung, and main's controller.error() on a cancelled stream is a spec no-op anyway. The catch block is back to plain controller.error(error).
  • The drain fix is actually in the diff this time. You were right that the described desiredSize check never made it in — sorry. streamFromAsyncIterable now sets a flag from the stream's cancel() callback and checks it at the top of each iteration, so the null-transform path stops pulling after cancel.
  • Tests: the cancel test no longer sleeps — the generator suspends on item 2, cancel lands while it's suspended, and the assertion is exact (pulled === 2, both the null-transform and the enqueueing variant). It fails against main (pulled = 200) and passes with the fix, which the old 50ms-sleep version never did. Also pinned that a genuine TypeError: Cannot read properties of undefined (reading 'closed') still propagates to the reader.

Local gates: format/lint/typecheck/knip/drift guards/build/build:lib/attw green; streaming suite 30/30. (helm/7z/node:sqlite don't exist in my environment, so those 181 unrelated tests fail with and without the patch — verified against pristine HEAD.)

Ready for another look whenever you have time.

@Matthew-Selvam

Copy link
Copy Markdown
Contributor Author

Heads-up on the red ❌: Cross-platform Tests (windows-latest) failed on two timeouts in tests that don't exist on this branch — they come from main (CI tests the merge commit):

  • tests/unit/test-runner-cli.test.ts › coverage merge — 30.6s against a 30s timeout
  • tests/unit/lib/agent/run-store-history.test.ts › 10,000-entry retention — 128.6s against a 120s timeout

Both pass locally on a pristine origin/main worktree (macOS arm64, bun 1.4.2, 16.8s and 11.1s), and the same job passed on main itself (d680f12). The job is new on main per the workflow comment ("not a required check yet; promote it once it has a history of being green") — looks like Windows runner slowness on its first PR-merge run rather than anything in this diff, which touches only src/lib/llm/utils/streaming.ts and its test. Could you rerun the failed job when you get a chance? I can't (needs admin).

@cevheri
cevheri merged commit 5011837 into libredb:main Sep 17, 2026
35 of 36 checks passed
@cevheri

cevheri commented Sep 17, 2026

Copy link
Copy Markdown
Member

thanks,
Also fix it timeout issue on main

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

security Supply-chain, auth, or hardening work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants