Skip to content

Commit 0ee26fc

Browse files
committed
fix(voyage): close response body stream when batch output JSONL parsing throws
The batch output file download path creates a readline interface over a Readable.fromWeb() response body stream. If JSON.parse throws on a malformed JSONL line, the for-await loop exits via exception but the readline interface and underlying Readable stream were never explicitly closed or destroyed, leaving the HTTP response body stream dangling. Extract the stream reading into , a testable helper that wraps the iteration in a try-finally so both reader.close() and inputStream.destroy() are always called, matching the pattern established in #98493 for the same class of leak.
1 parent 050e1f3 commit 0ee26fc

2 files changed

Lines changed: 133 additions & 16 deletions

File tree

extensions/voyage/embedding-batch.test.ts

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
// Voyage batch tests cover bounded status/error response reads.
22
import { describe, expect, it } from "vitest";
3-
import type { VoyageEmbeddingClient } from "./embedding-provider.js";
43
import { testing } from "./embedding-batch.js";
4+
import type { VoyageEmbeddingClient } from "./embedding-provider.js";
55

6-
const { fetchVoyageBatchStatus, readVoyageBatchError, VOYAGE_BATCH_RESPONSE_MAX_BYTES } = testing;
6+
const {
7+
fetchVoyageBatchStatus,
8+
readVoyageBatchError,
9+
readBatchOutputContent,
10+
VOYAGE_BATCH_RESPONSE_MAX_BYTES,
11+
} = testing;
712

813
function buildClient(): VoyageEmbeddingClient {
914
return {
@@ -215,3 +220,91 @@ describe("voyage batch bounded reads", () => {
215220
).rejects.toThrow(/voyage batch status failed: 503 voyage upstream is down/);
216221
});
217222
});
223+
224+
describe("readBatchOutputContent stream cleanup", () => {
225+
it("returns without error on a null body", async () => {
226+
const response = new Response(null, { status: 200 });
227+
const remaining = new Set<string>();
228+
const errors: string[] = [];
229+
const byCustomId = new Map<string, number[]>();
230+
231+
await expect(
232+
readBatchOutputContent(response, remaining, errors, byCustomId),
233+
).resolves.toBeUndefined();
234+
});
235+
236+
it("applies every valid JSONL line and tracks remaining and errors", async () => {
237+
const body = [
238+
JSON.stringify({
239+
custom_id: "req-0",
240+
response: { status_code: 200, body: { data: [{ embedding: [0.1, 0.2] }] } },
241+
}),
242+
JSON.stringify({
243+
custom_id: "req-1",
244+
response: { status_code: 200, body: { data: [{ embedding: [0.3, 0.4] }] } },
245+
}),
246+
JSON.stringify({ custom_id: "req-2", error: { message: "voyage failed" } }),
247+
"",
248+
].join("\n");
249+
const response = new Response(body, {
250+
status: 200,
251+
headers: { "content-type": "application/x-ndjson" },
252+
});
253+
const remaining = new Set(["req-0", "req-1", "req-2", "req-3"]);
254+
const errors: string[] = [];
255+
const byCustomId = new Map<string, number[]>();
256+
257+
await readBatchOutputContent(response, remaining, errors, byCustomId);
258+
259+
// valid lines should be removed from remaining
260+
expect(remaining.has("req-0")).toBe(false);
261+
expect(remaining.has("req-1")).toBe(false);
262+
// Error line is tracked; applyEmbeddingBatchOutputLine also removes
263+
// error custom_ids from remaining (both consumed and errored lines)
264+
expect(remaining.has("req-2")).toBe(false);
265+
expect(remaining.has("req-3")).toBe(true);
266+
// error line should be tracked in errors array
267+
expect(errors).toHaveLength(1);
268+
expect(errors[0]).toContain("req-2");
269+
});
270+
271+
it.runIf(process.platform === "linux")(
272+
"cancels the response stream when JSON.parse encounters malformed JSONL",
273+
async () => {
274+
let wasCanceled = false;
275+
const encoder = new TextEncoder();
276+
const stream = new ReadableStream<Uint8Array>({
277+
start(controller) {
278+
// First line: valid
279+
controller.enqueue(
280+
encoder.encode(
281+
JSON.stringify({
282+
custom_id: "req-0",
283+
response: { status_code: 200, body: { data: [{ embedding: [0.1] }] } },
284+
}) + "\n",
285+
),
286+
);
287+
// Second line: malformed JSON — this should trigger the stream cancel
288+
controller.enqueue(encoder.encode("{not valid json}\n"));
289+
},
290+
cancel() {
291+
wasCanceled = true;
292+
},
293+
});
294+
const response = new Response(stream, {
295+
status: 200,
296+
headers: { "content-type": "application/x-ndjson" },
297+
});
298+
const remaining = new Set(["req-0"]);
299+
const errors: string[] = [];
300+
const byCustomId = new Map<string, number[]>();
301+
302+
await expect(
303+
readBatchOutputContent(response, remaining, errors, byCustomId),
304+
).rejects.toThrow();
305+
306+
// The stream should have been destroyed/canceled, not left dangling
307+
expect(wasCanceled).toBe(true);
308+
},
309+
);
310+
});

extensions/voyage/embedding-batch.ts

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -317,20 +317,7 @@ export async function runVoyageEmbeddingBatches(
317317
if (!contentRes.body) {
318318
return;
319319
}
320-
const reader = createInterface({
321-
input: Readable.fromWeb(
322-
contentRes.body as unknown as import("stream/web").ReadableStream,
323-
),
324-
terminal: false,
325-
});
326-
327-
for await (const rawLine of reader) {
328-
if (!rawLine.trim()) {
329-
continue;
330-
}
331-
const line = JSON.parse(rawLine) as VoyageBatchOutputLine;
332-
applyEmbeddingBatchOutputLine({ line, remaining, errors, byCustomId });
333-
}
320+
await readBatchOutputContent(contentRes, remaining, errors, byCustomId);
334321
},
335322
});
336323

@@ -346,8 +333,45 @@ export async function runVoyageEmbeddingBatches(
346333
});
347334
}
348335

336+
/**
337+
* Stream-read a Voyage batch output file response body line by line, applying
338+
* each parsed output line through `applyEmbeddingBatchOutputLine`.
339+
*
340+
* The response body is an untrusted external stream. This helper wraps the
341+
* readline iteration in a try-finally so the readline interface and underlying
342+
* Node.js Readable are always closed / destroyed, even when JSON.parse or
343+
* applyEmbeddingBatchOutputLine throws.
344+
*/
345+
async function readBatchOutputContent(
346+
contentRes: Response,
347+
remaining: Set<string>,
348+
errors: string[],
349+
byCustomId: Map<string, number[]>,
350+
): Promise<void> {
351+
if (!contentRes.body) {
352+
return;
353+
}
354+
const inputStream = Readable.fromWeb(
355+
contentRes.body as unknown as import("stream/web").ReadableStream,
356+
);
357+
const reader = createInterface({ input: inputStream, terminal: false });
358+
try {
359+
for await (const rawLine of reader) {
360+
if (!rawLine.trim()) {
361+
continue;
362+
}
363+
const line = JSON.parse(rawLine) as VoyageBatchOutputLine;
364+
applyEmbeddingBatchOutputLine({ line, remaining, errors, byCustomId });
365+
}
366+
} finally {
367+
reader.close();
368+
inputStream.destroy();
369+
}
370+
}
371+
349372
export const testing = {
350373
fetchVoyageBatchStatus,
351374
readVoyageBatchError,
375+
readBatchOutputContent,
352376
VOYAGE_BATCH_RESPONSE_MAX_BYTES,
353377
} as const;

0 commit comments

Comments
 (0)