diff --git a/.changeset/async-iterable-stream-cancel.md b/.changeset/async-iterable-stream-cancel.md new file mode 100644 index 000000000..a78b033b4 --- /dev/null +++ b/.changeset/async-iterable-stream-cancel.md @@ -0,0 +1,7 @@ +--- +"@voltagent/internal": patch +--- + +Cancel the source stream when an `AsyncIterableStream` is iterated and stopped early + +`createAsyncIterableStream` returned an async iterator with no `return` method, so breaking out of a `for await...of` loop early left the reader lock held and never cancelled the underlying stream. It now implements `return` to cancel the reader, matching the built-in `ReadableStream` async iterator and tearing down the upstream when iteration stops early. diff --git a/packages/internal/src/utils/async-iterable-stream.spec.ts b/packages/internal/src/utils/async-iterable-stream.spec.ts index c58e9b03d..8ea63a715 100644 --- a/packages/internal/src/utils/async-iterable-stream.spec.ts +++ b/packages/internal/src/utils/async-iterable-stream.spec.ts @@ -31,4 +31,26 @@ describe("createAsyncIterableStream()", () => { expect(await convertReadableStreamToArray(asyncIterableStream)).toEqual(testData); }); + + it("should cancel the source stream when async iteration stops early", async () => { + let cancelled = false; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(1); + controller.enqueue(2); + controller.enqueue(3); + }, + cancel() { + cancelled = true; + }, + }); + + const asyncIterableStream = createAsyncIterableStream(source); + + for await (const _chunk of asyncIterableStream) { + break; // stop after the first chunk + } + + expect(cancelled).toBe(true); + }); }); diff --git a/packages/internal/src/utils/async-iterable-stream.ts b/packages/internal/src/utils/async-iterable-stream.ts index 26c06683a..c48850b3e 100644 --- a/packages/internal/src/utils/async-iterable-stream.ts +++ b/packages/internal/src/utils/async-iterable-stream.ts @@ -39,6 +39,13 @@ export function createAsyncIterableStream(source: ReadableStream): AsyncIt const { done, value } = await reader.read(); return done ? { done: true, value: undefined } : { done: false, value }; }, + // Called when the consumer stops early (break/throw/return). Mirror the + // built-in ReadableStream async iterator and cancel the source so the + // upstream is torn down instead of leaking the reader lock. + async return(): Promise> { + await reader.cancel(); + return { done: true, value: undefined }; + }, }; };