mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(ui): prevent oversized WebRTC SDP answers from hanging Talk setup (#119295)
* fix(ui): bound WebRTC SDP answer reads and release failed bodies * fix(ui): scope WebRTC SDP limits to OpenAI * fix(ui): release bounded response readers * test(ui): split WebRTC SDP browser coverage * fix(ui): reject unsafe declared response lengths * test(ui): reuse pending WebRTC SDP response fixture * test(ui): use native response for pending SDP body * style(ui): align pending SDP fixture formatting * test(ui): capture WebRTC SDP alert proof --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
@@ -169,6 +169,7 @@ describe("GPT-Live offer broker", () => {
|
||||
}
|
||||
expect(reservation).not.toHaveProperty("model");
|
||||
expect(reservation).not.toHaveProperty("voice");
|
||||
expect(reservation.offerResponseMaxBytes).toBe(256 * 1024);
|
||||
const response = createResponseHarness();
|
||||
const handling = realtime.handler(
|
||||
createRequest({
|
||||
|
||||
@@ -347,6 +347,7 @@ export function createOpenAIQuicksilverBrowserSessionBroker(params: {
|
||||
transport: "webrtc",
|
||||
clientSecret: token,
|
||||
offerUrl: OPENAI_QUICKSILVER_OFFER_PATH,
|
||||
offerResponseMaxBytes: 256 * 1024,
|
||||
...(request.gaSideband ? {} : { model, voice }),
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
@@ -307,6 +307,7 @@ describe("OpenAI realtime voice browser authentication", () => {
|
||||
transport: "webrtc",
|
||||
clientSecret: "client-secret-123",
|
||||
offerUrl: "https://api.openai.com/v1/realtime/calls",
|
||||
offerResponseMaxBytes: 256 * 1024,
|
||||
model: "gpt-realtime-2.1",
|
||||
expiresAt: 1_765_000_000_000,
|
||||
});
|
||||
|
||||
@@ -320,6 +320,7 @@ async function createOpenAIRealtimeBrowserSession(
|
||||
transport: "webrtc",
|
||||
clientSecret: clientSecret.value,
|
||||
offerUrl: "https://api.openai.com/v1/realtime/calls",
|
||||
offerResponseMaxBytes: 256 * 1024,
|
||||
...(offerHeaders ? { offerHeaders } : {}),
|
||||
model,
|
||||
voice,
|
||||
|
||||
@@ -271,6 +271,7 @@ type RealtimeVoiceBrowserWebRtcSdpSession = {
|
||||
clientSecret: string;
|
||||
offerUrl?: string;
|
||||
offerHeaders?: Record<string, string>;
|
||||
offerResponseMaxBytes?: number;
|
||||
model?: string;
|
||||
voice?: string;
|
||||
expiresAt?: number;
|
||||
|
||||
@@ -123,6 +123,27 @@ describe("custom theme import helpers", () => {
|
||||
).rejects.toThrow("too large");
|
||||
});
|
||||
|
||||
it("rejects unsafe tweakcn content-length values before acquiring the body reader", async () => {
|
||||
const cancel = vi.fn(() => Promise.resolve());
|
||||
const getReader = vi.fn(() => {
|
||||
throw new Error("reader should not be acquired");
|
||||
});
|
||||
const response = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "content-length": "9007199254740993" }),
|
||||
body: { cancel, getReader },
|
||||
url: "",
|
||||
} as unknown as Response;
|
||||
const fetchImpl = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
importCustomThemeFromUrl("https://tweakcn.com/themes/cmlhfpjhw000004l4f4ax3m7z", fetchImpl),
|
||||
).rejects.toThrow("too large");
|
||||
expect(getReader).not.toHaveBeenCalled();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects tweakcn theme responses without a bounded body stream", async () => {
|
||||
const response = createResponse(JSON.stringify(createTweakcnPayload()), { body: null });
|
||||
const fetchImpl = vi.fn(async () => response) as unknown as typeof fetch;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { asNullableRecord as readThemeRecord } from "@openclaw/normalization-cor
|
||||
// Control UI module implements custom theme behavior.
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { readResponseTextWithLimit } from "../lib/response-body.ts";
|
||||
|
||||
const TWEAKCN_HOSTS = new Set(["tweakcn.com", "www.tweakcn.com"]);
|
||||
const THEME_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
||||
@@ -473,51 +474,12 @@ function assertTweakcnResponseUrl(value: string | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentLength(headers: Headers): number | null {
|
||||
const raw = headers.get("content-length");
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
async function readResponseTextWithLimit(response: Response): Promise<string> {
|
||||
const contentLength = parseContentLength(response.headers);
|
||||
if (contentLength != null && contentLength > MAX_TWEAKCN_THEME_BYTES) {
|
||||
throw new Error("tweakcn theme payload is too large.");
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("tweakcn returned an unreadable theme payload.");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let bytes = 0;
|
||||
let text = "";
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) {
|
||||
break;
|
||||
}
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > MAX_TWEAKCN_THEME_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new Error("tweakcn theme payload is too large.");
|
||||
}
|
||||
text += decoder.decode(chunk.value, { stream: true });
|
||||
}
|
||||
text += decoder.decode();
|
||||
return text;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonResponseWithLimit(response: Response): Promise<unknown> {
|
||||
const text = await readResponseTextWithLimit(response);
|
||||
const text = await readResponseTextWithLimit(response, {
|
||||
maxBytes: MAX_TWEAKCN_THEME_BYTES,
|
||||
tooLargeMessage: "tweakcn theme payload is too large.",
|
||||
missingBodyMessage: "tweakcn returned an unreadable theme payload.",
|
||||
});
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
|
||||
@@ -2,6 +2,19 @@ import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "playwright";
|
||||
|
||||
export type WebRtcSdpE2eProof = {
|
||||
bodyCancelCount: number;
|
||||
bodyCancelResolvedCount: number;
|
||||
fetchCount: number;
|
||||
remoteDescriptionCount: number;
|
||||
statuses: number[];
|
||||
};
|
||||
|
||||
type WebRtcSdpResponseFixture = {
|
||||
body: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export function videoTalkCatalog(activeProvider: "google" | "openai") {
|
||||
return {
|
||||
realtime: {
|
||||
@@ -91,6 +104,100 @@ export async function installTalkBrowserFixtures(page: Page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function installWebRtcSdpResponseFixture(page: Page, fixture: WebRtcSdpResponseFixture) {
|
||||
await page.addInitScript(() => {
|
||||
const proofWindow = window as Window & { openclawWebRtcSdpE2e?: WebRtcSdpE2eProof };
|
||||
proofWindow.openclawWebRtcSdpE2e = {
|
||||
bodyCancelCount: 0,
|
||||
bodyCancelResolvedCount: 0,
|
||||
fetchCount: 0,
|
||||
remoteDescriptionCount: 0,
|
||||
statuses: [],
|
||||
};
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input, init) => {
|
||||
const response = await originalFetch(input, init);
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (!url.includes("api.openai.com/v1/realtime/calls")) {
|
||||
return response;
|
||||
}
|
||||
const proof = proofWindow.openclawWebRtcSdpE2e;
|
||||
if (!proof || !response.body) {
|
||||
return response;
|
||||
}
|
||||
proof.fetchCount += 1;
|
||||
proof.statuses.push(response.status);
|
||||
const originalCancel = response.body.cancel.bind(response.body);
|
||||
response.body.cancel = async (reason) => {
|
||||
proof.bodyCancelCount += 1;
|
||||
try {
|
||||
return await originalCancel(reason);
|
||||
} finally {
|
||||
proof.bodyCancelResolvedCount += 1;
|
||||
}
|
||||
};
|
||||
return response;
|
||||
};
|
||||
|
||||
class FakeDataChannel extends EventTarget {
|
||||
readyState = "open";
|
||||
send() {}
|
||||
close() {
|
||||
this.readyState = "closed";
|
||||
}
|
||||
}
|
||||
|
||||
class FakePeerConnection extends EventTarget {
|
||||
connectionState = "new";
|
||||
sctp = { maxMessageSize: 256 * 1024 };
|
||||
channel = new FakeDataChannel();
|
||||
addTrack() {}
|
||||
createDataChannel() {
|
||||
return this.channel;
|
||||
}
|
||||
async createOffer() {
|
||||
return { type: "offer" as const, sdp: "offer-sdp" };
|
||||
}
|
||||
async setLocalDescription() {}
|
||||
async setRemoteDescription() {
|
||||
const proof = proofWindow.openclawWebRtcSdpE2e;
|
||||
if (proof) {
|
||||
proof.remoteDescriptionCount += 1;
|
||||
}
|
||||
}
|
||||
close() {
|
||||
this.connectionState = "closed";
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, "RTCPeerConnection", {
|
||||
configurable: true,
|
||||
value: FakePeerConnection,
|
||||
});
|
||||
});
|
||||
await page.route("https://api.openai.com/v1/realtime/calls", async (route) => {
|
||||
await route.fulfill({
|
||||
status: fixture.status,
|
||||
contentType: "application/sdp",
|
||||
body: fixture.body,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function installWebRtcSdpFailureFixture(page: Page) {
|
||||
await installWebRtcSdpResponseFixture(page, {
|
||||
status: 502,
|
||||
body: "provider failure",
|
||||
});
|
||||
}
|
||||
|
||||
export async function installOversizedWebRtcSdpFixture(page: Page) {
|
||||
await installWebRtcSdpResponseFixture(page, {
|
||||
status: 200,
|
||||
body: "x".repeat(256 * 1024 + 1),
|
||||
});
|
||||
}
|
||||
|
||||
export async function captureComposerProof(page: Page, fileName: string) {
|
||||
const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "voice-controls");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
@@ -107,6 +214,14 @@ export async function captureVideoTalkProof(page: Page, fileName: string) {
|
||||
.screenshot({ path: path.join(artifactDir, fileName) });
|
||||
}
|
||||
|
||||
export async function captureWebRtcSdpAlertProof(page: Page, fileName: string) {
|
||||
const artifactDir = path.join(process.cwd(), ".artifacts", "control-ui-e2e", "webrtc-sdp");
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
await page
|
||||
.locator('.agent-chat__talk-status[role="alert"]')
|
||||
.screenshot({ path: path.join(artifactDir, fileName) });
|
||||
}
|
||||
|
||||
export async function installBlockedMicrophoneFixture(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Control UI E2E tests cover WebRTC SDP response handling through a real page.
|
||||
import { expect, it } from "vitest";
|
||||
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
import {
|
||||
captureWebRtcSdpAlertProof,
|
||||
installOversizedWebRtcSdpFixture,
|
||||
installWebRtcSdpFailureFixture,
|
||||
type WebRtcSdpE2eProof,
|
||||
videoTalkCatalog,
|
||||
} from "./browser-talk-start-stop.fixtures.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({
|
||||
name: "Control UI browser Talk WebRTC SDP responses",
|
||||
browserLaunchOptions: {
|
||||
args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
|
||||
},
|
||||
});
|
||||
|
||||
suite.define(() => {
|
||||
it("cancels a failed OpenAI WebRTC SDP response body in the live Control UI", async () => {
|
||||
await suite.withPage({ permissions: ["microphone"] }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"talk.catalog": videoTalkCatalog("openai"),
|
||||
"talk.client.create": {
|
||||
provider: "openai",
|
||||
voiceSessionId: "voice-openai-sdp-error-e2e",
|
||||
transport: "webrtc",
|
||||
clientSecret: "test-client-secret",
|
||||
offerUrl: "https://api.openai.com/v1/realtime/calls",
|
||||
offerResponseMaxBytes: 256 * 1024,
|
||||
},
|
||||
},
|
||||
});
|
||||
await installWebRtcSdpFailureFixture(page);
|
||||
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await page.getByRole("button", { name: "Start voice input" }).click();
|
||||
await gateway.waitForRequest("talk.client.create");
|
||||
|
||||
const alert = page.locator('.agent-chat__talk-status[role="alert"]');
|
||||
await expect.poll(() => alert.textContent()).toContain("Realtime WebRTC setup failed (502)");
|
||||
await captureWebRtcSdpAlertProof(page, "01-http-failure-alert.png");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as Window & { openclawWebRtcSdpE2e?: WebRtcSdpE2eProof })
|
||||
.openclawWebRtcSdpE2e,
|
||||
),
|
||||
)
|
||||
.toEqual({
|
||||
bodyCancelCount: 1,
|
||||
bodyCancelResolvedCount: 1,
|
||||
fetchCount: 1,
|
||||
remoteDescriptionCount: 0,
|
||||
statuses: [502],
|
||||
});
|
||||
console.info(
|
||||
`[webrtc-sdp-e2e] trigger=OpenAI WebRTC offer; transition=status:error+502; ` +
|
||||
`body.cancel=1/resolved; outcome=visible setup failure`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects and cancels an oversized OpenAI SDP answer before peer setup", async () => {
|
||||
await suite.withPage({ permissions: ["microphone"] }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"talk.catalog": videoTalkCatalog("openai"),
|
||||
"talk.client.create": {
|
||||
provider: "openai",
|
||||
voiceSessionId: "voice-openai-sdp-oversized-e2e",
|
||||
transport: "webrtc",
|
||||
clientSecret: "test-client-secret",
|
||||
offerUrl: "https://api.openai.com/v1/realtime/calls",
|
||||
offerResponseMaxBytes: 256 * 1024,
|
||||
},
|
||||
},
|
||||
});
|
||||
await installOversizedWebRtcSdpFixture(page);
|
||||
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await page.getByRole("button", { name: "Start voice input" }).click();
|
||||
await gateway.waitForRequest("talk.client.create");
|
||||
|
||||
const alert = page.locator('.agent-chat__talk-status[role="alert"]');
|
||||
await expect
|
||||
.poll(() => alert.textContent())
|
||||
.toContain("Realtime WebRTC SDP answer: text response exceeds 262144 bytes");
|
||||
await captureWebRtcSdpAlertProof(page, "02-oversized-answer-alert.png");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as Window & { openclawWebRtcSdpE2e?: WebRtcSdpE2eProof })
|
||||
.openclawWebRtcSdpE2e,
|
||||
),
|
||||
)
|
||||
.toEqual({
|
||||
bodyCancelCount: 1,
|
||||
bodyCancelResolvedCount: 1,
|
||||
fetchCount: 1,
|
||||
remoteDescriptionCount: 0,
|
||||
statuses: [200],
|
||||
});
|
||||
console.info(
|
||||
`[webrtc-sdp-e2e] trigger=oversized OpenAI SDP answer; ` +
|
||||
`body.cancel=1/resolved; remote-description=0; outcome=visible size failure`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Control UI response helpers own bounded browser body consumption.
|
||||
|
||||
type ResponseTextLimitOptions = {
|
||||
maxBytes: number;
|
||||
tooLargeMessage: string;
|
||||
missingBodyMessage?: string;
|
||||
};
|
||||
|
||||
function parseContentLength(headers: Headers): number | null {
|
||||
const raw = headers.get("content-length");
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (!/^\d+$/u.test(raw)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isSafeInteger(parsed) ? parsed : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
export async function readResponseTextWithLimit(
|
||||
response: Response,
|
||||
options: ResponseTextLimitOptions,
|
||||
): Promise<string> {
|
||||
const contentLength = parseContentLength(response.headers);
|
||||
if (contentLength !== null && contentLength > options.maxBytes) {
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
throw new Error(options.tooLargeMessage);
|
||||
}
|
||||
if (!response.body) {
|
||||
if (options.missingBodyMessage) {
|
||||
throw new Error(options.missingBodyMessage);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const chunks: string[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
chunks.push(tail);
|
||||
}
|
||||
break;
|
||||
}
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > options.maxBytes) {
|
||||
// Cancellation is best-effort; finally always releases this reader's lock.
|
||||
void reader.cancel().catch(() => undefined);
|
||||
throw new Error(options.tooLargeMessage);
|
||||
}
|
||||
chunks.push(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export type RealtimeTalkWebRtcSdpSessionResult = {
|
||||
clientSecret: string;
|
||||
offerUrl?: string;
|
||||
offerHeaders?: Record<string, string>;
|
||||
offerResponseMaxBytes?: number;
|
||||
model?: string;
|
||||
voice?: string;
|
||||
expiresAt?: number;
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RealtimeTalkWebRtcOfferExchange } from "./realtime-talk-webrtc-support.ts";
|
||||
|
||||
const OPENAI_REALTIME_SDP_ANSWER_MAX_BYTES = 256 * 1024;
|
||||
|
||||
function readAnswer(
|
||||
exchange: RealtimeTalkWebRtcOfferExchange,
|
||||
isCurrent = () => true,
|
||||
offerResponseMaxBytes: number | null = OPENAI_REALTIME_SDP_ANSWER_MAX_BYTES,
|
||||
provider = "openai",
|
||||
) {
|
||||
return exchange.readAnswer({
|
||||
session: {
|
||||
provider,
|
||||
transport: "webrtc",
|
||||
clientSecret: "reservation-token",
|
||||
offerUrl: "https://gateway.example.test/realtime/calls",
|
||||
...(offerResponseMaxBytes === null ? {} : { offerResponseMaxBytes }),
|
||||
},
|
||||
offer: { type: "offer", sdp: "offer-sdp" },
|
||||
gatewayUrl: "wss://gateway.example.test/control",
|
||||
isCurrent,
|
||||
});
|
||||
}
|
||||
|
||||
describe("RealtimeTalkWebRtcOfferExchange", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -34,4 +56,140 @@ describe("RealtimeTalkWebRtcOfferExchange", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts an SDP answer at the 256 KiB boundary", async () => {
|
||||
const answer = "x".repeat(OPENAI_REALTIME_SDP_ANSWER_MAX_BYTES);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(answer)),
|
||||
);
|
||||
const exchange = new RealtimeTalkWebRtcOfferExchange();
|
||||
|
||||
await expect(readAnswer(exchange)).resolves.toBe(answer);
|
||||
});
|
||||
|
||||
it("preserves oversized SDP answers when the provider declares no limit", async () => {
|
||||
const answer = "x".repeat(OPENAI_REALTIME_SDP_ANSWER_MAX_BYTES + 1);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(answer)),
|
||||
);
|
||||
const exchange = new RealtimeTalkWebRtcOfferExchange();
|
||||
|
||||
await expect(readAnswer(exchange, () => true, null, "openai")).resolves.toBe(answer);
|
||||
});
|
||||
|
||||
it("honors a response limit declared by a custom provider", async () => {
|
||||
const answer = "x".repeat(OPENAI_REALTIME_SDP_ANSWER_MAX_BYTES + 1);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(answer)),
|
||||
);
|
||||
const exchange = new RealtimeTalkWebRtcOfferExchange();
|
||||
|
||||
await expect(
|
||||
readAnswer(exchange, () => true, OPENAI_REALTIME_SDP_ANSWER_MAX_BYTES, "custom-provider"),
|
||||
).rejects.toThrow("Realtime WebRTC SDP answer: text response exceeds 262144 bytes");
|
||||
});
|
||||
|
||||
it("rejects and cancels a streamed SDP answer over the 256 KiB boundary", async () => {
|
||||
const cancel = vi.fn(() => Promise.resolve());
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(OPENAI_REALTIME_SDP_ANSWER_MAX_BYTES));
|
||||
controller.enqueue(new Uint8Array(1));
|
||||
},
|
||||
cancel,
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
body,
|
||||
text: vi.fn(),
|
||||
}) as unknown as Response,
|
||||
),
|
||||
);
|
||||
const exchange = new RealtimeTalkWebRtcOfferExchange();
|
||||
|
||||
await expect(readAnswer(exchange)).rejects.toThrow(
|
||||
"Realtime WebRTC SDP answer: text response exceeds 262144 bytes",
|
||||
);
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(body.locked).toBe(false);
|
||||
});
|
||||
|
||||
it.each([String(OPENAI_REALTIME_SDP_ANSWER_MAX_BYTES + 1), "9007199254740993"])(
|
||||
"rejects a declared oversized SDP answer of %s before acquiring its body reader",
|
||||
async (contentLength) => {
|
||||
const cancel = vi.fn(() => Promise.resolve());
|
||||
const getReader = vi.fn(() => {
|
||||
throw new Error("reader should not be acquired");
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
"content-length": contentLength,
|
||||
}),
|
||||
body: { cancel, getReader },
|
||||
text: vi.fn(),
|
||||
}) as unknown as Response,
|
||||
),
|
||||
);
|
||||
const exchange = new RealtimeTalkWebRtcOfferExchange();
|
||||
|
||||
await expect(readAnswer(exchange)).rejects.toThrow(
|
||||
"Realtime WebRTC SDP answer: text response exceeds 262144 bytes",
|
||||
);
|
||||
expect(getReader).not.toHaveBeenCalled();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it("cancels a non-2xx SDP response body without waiting for cancellation", async () => {
|
||||
const cancel = vi.fn(() => new Promise<void>(() => {}));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: false,
|
||||
status: 502,
|
||||
body: { cancel },
|
||||
}) as unknown as Response,
|
||||
),
|
||||
);
|
||||
const exchange = new RealtimeTalkWebRtcOfferExchange();
|
||||
|
||||
await expect(readAnswer(exchange)).rejects.toThrow("Realtime WebRTC setup failed (502)");
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("cancels a stale successful SDP response body", async () => {
|
||||
const cancel = vi.fn(() => Promise.resolve());
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { cancel },
|
||||
}) as unknown as Response,
|
||||
),
|
||||
);
|
||||
const exchange = new RealtimeTalkWebRtcOfferExchange();
|
||||
|
||||
await expect(readAnswer(exchange, () => false)).resolves.toBeUndefined();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Control UI chat module owns low-level WebRTC offer and media-message helpers.
|
||||
import { normalizeRealtimeVoiceResponseOutcome } from "../../../../src/talk/provider-types.js";
|
||||
import { readResponseTextWithLimit } from "../../lib/response-body.ts";
|
||||
import type { RealtimeTalkWebRtcSdpSessionResult } from "./realtime-talk-shared.ts";
|
||||
import type { RealtimeTalkVideoFrame } from "./realtime-talk-video.ts";
|
||||
|
||||
@@ -143,14 +144,23 @@ export class RealtimeTalkWebRtcOfferExchange {
|
||||
throw error;
|
||||
}
|
||||
if (!params.isCurrent()) {
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
return undefined;
|
||||
}
|
||||
if (!response.ok) {
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
throw new Error(`Realtime WebRTC setup failed (${response.status})`);
|
||||
}
|
||||
let answer: string;
|
||||
try {
|
||||
answer = await response.text();
|
||||
const maxBytes = params.session.offerResponseMaxBytes;
|
||||
answer =
|
||||
maxBytes === undefined
|
||||
? await response.text()
|
||||
: await readResponseTextWithLimit(response, {
|
||||
maxBytes,
|
||||
tooLargeMessage: `Realtime WebRTC SDP answer: text response exceeds ${maxBytes} bytes`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!params.isCurrent()) {
|
||||
return undefined;
|
||||
|
||||
@@ -78,6 +78,23 @@ function stubAnswerSdpFetch(): void {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("answer-sdp")) as unknown as typeof fetch);
|
||||
}
|
||||
|
||||
function createPendingSdpResponse(signal: AbortSignal | undefined): Response {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
const reason = signal?.reason;
|
||||
controller.error(reason instanceof Error ? reason : new Error("offer request aborted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createOpenAiTransport(
|
||||
client: Record<string, unknown> = {},
|
||||
callbacks: Record<string, unknown> = {},
|
||||
@@ -88,6 +105,7 @@ function createOpenAiTransport(
|
||||
provider: "openai",
|
||||
transport: "webrtc",
|
||||
clientSecret: "client-secret-123",
|
||||
offerResponseMaxBytes: 256 * 1024,
|
||||
},
|
||||
{
|
||||
client: client as never,
|
||||
@@ -368,23 +386,11 @@ describe("WebRtcSdpRealtimeTalkTransport", () => {
|
||||
it("aborts stalled WebRTC SDP answer body reads after the offer timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
let offerSignal: AbortSignal | undefined;
|
||||
let response: Response | undefined;
|
||||
const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
offerSignal = init?.signal ?? undefined;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () =>
|
||||
new Promise<string>((_, reject) => {
|
||||
offerSignal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
const reason = offerSignal?.reason;
|
||||
reject(reason instanceof Error ? reason : new Error("offer request aborted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
} as Response;
|
||||
response = createPendingSdpResponse(offerSignal);
|
||||
return response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const transport = createOpenAiTransport();
|
||||
@@ -396,6 +402,7 @@ describe("WebRtcSdpRealtimeTalkTransport", () => {
|
||||
|
||||
await waitForFast(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(offerSignal?.aborted).toBe(false);
|
||||
expect(response?.body?.locked).toBe(true);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
@@ -403,38 +410,29 @@ describe("WebRtcSdpRealtimeTalkTransport", () => {
|
||||
new Error("Realtime WebRTC offer request timed out after 30000ms"),
|
||||
);
|
||||
expect(offerSignal?.aborted).toBe(true);
|
||||
expect(response?.body?.locked).toBe(false);
|
||||
});
|
||||
|
||||
it("aborts a pending WebRTC SDP answer body read when stopped", async () => {
|
||||
let offerSignal: AbortSignal | undefined;
|
||||
let response: Response | undefined;
|
||||
const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
offerSignal = init?.signal ?? undefined;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () =>
|
||||
new Promise<string>((_, reject) => {
|
||||
offerSignal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
const reason = offerSignal?.reason;
|
||||
reject(reason instanceof Error ? reason : new Error("offer request aborted"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
} as Response;
|
||||
response = createPendingSdpResponse(offerSignal);
|
||||
return response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
const transport = createOpenAiTransport();
|
||||
|
||||
const startResult = transport.start();
|
||||
await waitForFast(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
expect(response?.body?.locked).toBe(true);
|
||||
|
||||
transport.stop();
|
||||
|
||||
await expect(startResult).resolves.toBe("cancelled");
|
||||
expect(offerSignal?.aborted).toBe(true);
|
||||
expect(response?.body?.locked).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a closed candidate when the peer fails during final setup", async () => {
|
||||
|
||||
Reference in New Issue
Block a user