Skip to content

fix(agents): per-iteration abort promise in readStream prevents chunk retention#2047

Open
Saravut-Lin wants to merge 1 commit into
livekit:mainfrom
Saravut-Lin:fix/readstream-abort-promise-retention
Open

fix(agents): per-iteration abort promise in readStream prevents chunk retention#2047
Saravut-Lin wants to merge 1 commit into
livekit:mainfrom
Saravut-Lin:fix/readstream-abort-promise-retention

Conversation

@Saravut-Lin

Copy link
Copy Markdown

Fixes #2046.

What

readStream() hoisted a single waitForAbort(signal) promise outside its read loop and raced it against every reader.read(). Each ThrowsPromise.race registers a new reaction on that long-lived promise; since it only settles when the signal aborts (normally stream teardown), every reaction — and the settled read result it transitively holds — stays reachable for the stream's entire lifetime. AudioRecognition pumps sttInputStream through readStream(), so pipeline AgentSessions retained every inbound AudioFrame (~100/s), ~5.8 MB/min measured in production (details, heap-snapshot retainer chains, and prod before/after numbers in #2046).

Same mechanism as #1950 (the Deepgram plugin's private pump, fixed by #1957); this is the core-utils instance. The fix mirrors #1957's idiom exactly: a per-iteration abort promise with removeEventListener cleanup after each race, so the signal holds at most one listener at a time and no reaction outlives its iteration. Abort semantics are unchanged (pending read() still interrupted; if (!result) break path identical).

Tests

Three cases in utils.test.ts:

  1. Delivery + listener hygiene — 50 chunks through one signal; all delivered; listenerCount('abort') ≤ 1.
  2. Abort interrupts a pending read — generator returns instead of hanging on a silent stream.
  3. Retention regression (it.skipIf without --expose-gc) — 20 consumed chunks tracked via WeakRef; after GC, at most 1 remains reachable while the stream and signal stay live. On the old code all 20 stay pinned, so this is the test that actually catches the bug — worth noting that a listener-count assertion alone cannot (the old code also held exactly one listener; the retention lives in the promise reaction chain, not the listener list — this also applies to the regression test in fix(deepgram): per-iteration abort promise prevents AudioFrame retention on long-running streams #1957).

Verified locally: the retention test fails on main (20/20 chunks alive) and passes with the fix.

Production verification

Shipped as a patch on @livekit/agents@1.4.5 in a production voice agent: per-turn heapUsed went from 72→158 MB climbing over 26 turns (no plateau, forced GC reclaimed nothing) to 72→85 MB flat over 23 turns; job-process RSS high-water 275 MB vs ~360 MB.

Note for maintainers

The hoisted-waitForAbort-raced-in-a-loop shape also appears in several plugin pumps (e.g. cartesia/src/stt.ts, assemblyai/src/stt.ts, inworld/src/stt.ts, sarvam/src/stt.ts, soniox/src/stt.ts, xai/src/stt.ts, baseten/src/tts.ts). Not all are per-frame loops, but they're the same class as #1950 and may deserve an audit; this PR deliberately stays scoped to the core readStream().

… retention

readStream() hoisted a single waitForAbort(signal) promise outside its
read loop; every ThrowsPromise.race([reader.read(), abortPromise])
registered a new reaction on that long-lived promise, and each reaction
kept its iteration's settled read result reachable until the signal
aborted. AudioRecognition pumps sttInputStream through readStream(), so
pipeline AgentSessions retained every inbound AudioFrame (~100/s,
~5.8 MB/min measured in production).

Same mechanism as livekit#1950 (Deepgram plugin pump, fixed in livekit#1957); this is
the core-utils instance. Fix mirrors livekit#1957: per-iteration abort promise
with removeEventListener cleanup after each race. Adds a WeakRef+gc
regression test that fails on the old code (20/20 chunks pinned) plus
delivery and abort-interrupt coverage.

Fixes livekit#2046

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Saravut-Lin
Saravut-Lin requested a review from a team as a code owner July 16, 2026 12:12
@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: cd3db7b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 37 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@toubatbrian toubatbrian left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

readStream() still leaves the per-iteration abort listener/reaction reachable when reader.read() rejects, because abortCleanup?.() is only reached after a successful Promise.race().

Please wrap the race in try/finally so cleanup runs when the read resolves, rejects, or abort wins, and add a deterministic rejection-path test that asserts the abort listener is removed. I reproduced the failure and verified that this fix passes agents/src/utils.test.ts (50 passed, 1 skipped), formatting, scoped lint, and the full build.

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.

readStream() retains every chunk until its signal aborts — Promise.race against a long-lived abort promise (core twin of #1950)

3 participants