Skip to content

Commit 3b93266

Browse files
RenKoya1claude
andcommitted
fix(client): cancel the SSE stream on teardown to avoid leaking connections
`readFrom` (used by `parseSseStream` for both the JSON-RPC and REST client transports) only called `reader.releaseLock()` in its `finally`. When a consumer stops iterating early — a `break`, a `throw`, or a REST transport that throws on an `error` event — the async generator's `return()`/`throw()` runs that `finally`, which detaches the reader but never cancels the underlying `ReadableStream`. The fetch body (and its socket) is left open, so repeated early terminations leak connections. Cancel the reader on teardown so cancellation propagates to the response body. On normal completion the stream is already closed and `cancel()` is a no-op; on an errored stream `cancel()` rejects with the same error, which is ignored so the original error still surfaces. Adds regression tests asserting the underlying stream is canceled when the consumer breaks early and when it throws mid-iteration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5833652 commit 3b93266

2 files changed

Lines changed: 58 additions & 0 deletions

File tree

src/sse_utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,10 @@ async function* readFrom(stream: ReadableStream<string>): AsyncGenerator<string,
144144
yield value;
145145
}
146146
} finally {
147+
// `releaseLock()` alone leaves the body un-cancelled, leaking the
148+
// connection when a consumer breaks/throws early. `.catch()` swallows the
149+
// rejection cancel() produces on an already-errored stream.
150+
await reader.cancel().catch(() => {});
147151
reader.releaseLock();
148152
}
149153
}

test/sse_utils.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,60 @@ describe('SSE Utils', () => {
177177
});
178178
});
179179

180+
describe('parseSseStream teardown', () => {
181+
it('cancels the underlying stream when the consumer stops early', async () => {
182+
// An early break must cancel the response body, not just release the lock.
183+
let sourceCancelled = false;
184+
const stream = new ReadableStream<Uint8Array>({
185+
start(controller) {
186+
// One event, then stay open — a long-lived SSE connection.
187+
controller.enqueue(new TextEncoder().encode('data: {"id":1}\n\n'));
188+
},
189+
cancel() {
190+
sourceCancelled = true;
191+
},
192+
});
193+
const response = new Response(stream, {
194+
headers: { 'Content-Type': 'text/event-stream' },
195+
});
196+
197+
const seen: SseEvent[] = [];
198+
for await (const event of parseSseStream(response)) {
199+
seen.push(event);
200+
break;
201+
}
202+
203+
expect(seen).toHaveLength(1);
204+
expect(sourceCancelled).toBe(true);
205+
});
206+
207+
it('cancels the underlying stream when the consumer throws', async () => {
208+
let sourceCancelled = false;
209+
const stream = new ReadableStream<Uint8Array>({
210+
start(controller) {
211+
controller.enqueue(new TextEncoder().encode('data: {"id":1}\n\n'));
212+
},
213+
cancel() {
214+
sourceCancelled = true;
215+
},
216+
});
217+
const response = new Response(stream, {
218+
headers: { 'Content-Type': 'text/event-stream' },
219+
});
220+
221+
await expect(
222+
(async () => {
223+
for await (const event of parseSseStream(response)) {
224+
expect(event.data).toBe('{"id":1}');
225+
throw new Error('consumer boom');
226+
}
227+
})()
228+
).rejects.toThrow('consumer boom');
229+
230+
expect(sourceCancelled).toBe(true);
231+
});
232+
});
233+
180234
describe('Symmetry: parser understands formatter output', () => {
181235
it('should parse what formatSSEEvent produces', async () => {
182236
const originalData = { kind: 'task', id: '123', status: 'completed' };

0 commit comments

Comments
 (0)