diff --git a/open-sse/executors/windsurf.ts b/open-sse/executors/windsurf.ts index 19924afa2a..d87e245149 100644 --- a/open-sse/executors/windsurf.ts +++ b/open-sse/executors/windsurf.ts @@ -248,8 +248,16 @@ function buildGetChatMessageRequest( // ─── gRPC-web framing ──────────────────────────────────────────────────────── -/** Wrap a protobuf message in a 5-byte gRPC-web data frame. */ -function grpcWebFrame(payload: Uint8Array): Uint8Array { +/** + * Wrap a protobuf message in a 5-byte gRPC-web data frame. + * + * Returns `Uint8Array`, not bare `Uint8Array`: the frame is + * allocated with `new Uint8Array(length)`, which is always ArrayBuffer-backed, + * and only that narrower form satisfies `BodyInit` at the `fetch` call below. + * Bare `Uint8Array` widens to `Uint8Array`, which admits + * `SharedArrayBuffer` and is therefore rejected as a request body. + */ +function grpcWebFrame(payload: Uint8Array): Uint8Array { const frame = new Uint8Array(5 + payload.length); frame[0] = 0x00; // compression flag: no compression const view = new DataView(frame.buffer); diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index 55762eb4cf..42beddd03c 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -72,11 +72,20 @@ function getUploadedFileName(file: Blob & { name?: unknown }): string { return typeof file.name === "string" && file.name.length > 0 ? file.name : "audio.wav"; } +/** + * Assembles a multipart/form-data body for the audio upload endpoints. + * + * `body` is typed `Uint8Array` rather than bare `Uint8Array`: it is + * allocated with `new Uint8Array(totalLength)`, which is always ArrayBuffer-backed, + * and only that narrower form satisfies `BodyInit`. Bare `Uint8Array` widens to + * `Uint8Array`, which admits `SharedArrayBuffer` and is therefore + * rejected by every `fetch` that passes this straight through as the request body. + */ export async function buildMultipartBody( file: Blob & { name?: unknown }, fields: Record, fileFieldName = "file" -): Promise<{ body: Uint8Array; contentType: string }> { +): Promise<{ body: Uint8Array; contentType: string }> { const boundary = "----OmniRouteAudioBoundary" + Date.now().toString(36); const parts: Uint8Array[] = []; const encoder = new TextEncoder(); diff --git a/tests/unit/multipart-body-arraybuffer-backing.test.ts b/tests/unit/multipart-body-arraybuffer-backing.test.ts new file mode 100644 index 0000000000..b267416f06 --- /dev/null +++ b/tests/unit/multipart-body-arraybuffer-backing.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildMultipartBody } = await import("../../open-sse/handlers/audioTranscription.ts"); + +/** + * `buildMultipartBody` is handed straight to `fetch` as the request body by six + * call sites across `audioTranscription.ts` and `audioTranslation.ts`, so its + * result must satisfy `BodyInit` — which requires an `ArrayBuffer`-backed view, + * not merely "some Uint8Array". A `SharedArrayBuffer`-backed view, or a slice + * into a shared pool, is not a valid request body. + * + * The four existing `buildMultipartBody` tests in + * `audio-transcription-handler.test.ts` decode the payload and assert its + * *contents* (boundary, filename sanitization, MIME fallback). None of them + * assert the backing, which is the property the declared + * `Uint8Array` return type exists to guarantee — and the one a + * plausible "optimization" to a pooled `Buffer.concat` would break. + */ + +function audioFile(name = "clip.wav", type = "audio/wav") { + return Object.assign(new Blob([new Uint8Array([1, 2, 3, 4])], { type }), { name }); +} + +test("the assembled body is backed by a plain ArrayBuffer, never a shared one", async () => { + const { body } = await buildMultipartBody(audioFile(), { model: "whisper-1" }); + + assert.ok(body instanceof Uint8Array); + assert.ok( + body.buffer instanceof ArrayBuffer, + "a SharedArrayBuffer-backed view is not accepted as a fetch BodyInit" + ); +}); + +test("the assembled body owns its whole buffer — not a view into a shared pool", async () => { + const { body } = await buildMultipartBody(audioFile(), { model: "whisper-1" }); + + // `new Uint8Array(totalLength)` allocates exclusively. A pooled allocation + // (e.g. switching to Buffer.concat) would leave a non-zero byteOffset and a + // buffer larger than the payload, so the bytes handed to fetch would no + // longer be the whole buffer. + assert.equal(body.byteOffset, 0, "body must start at offset 0 of its own buffer"); + assert.equal( + body.byteLength, + body.buffer.byteLength, + "body must span its entire buffer, with no pooled remainder" + ); +}); + +test("the backing holds for a larger payload than a single pool slab", async () => { + // 64 KiB — comfortably past Node's 8 KiB Buffer pool threshold, so a pooled + // implementation would behave differently here than for the small case above. + const big = Object.assign(new Blob([new Uint8Array(64 * 1024)], { type: "audio/wav" }), { + name: "big.wav", + }); + const { body } = await buildMultipartBody(big, { model: "whisper-1" }); + + assert.ok(body.buffer instanceof ArrayBuffer); + assert.equal(body.byteOffset, 0); + assert.equal(body.byteLength, body.buffer.byteLength); + assert.ok(body.byteLength > 64 * 1024, "payload carries the file plus the multipart envelope"); +});