Skip to content

Commit 4c55dd8

Browse files
dahifivincentkoc
authored andcommitted
fix(ui): guard WebRTC Talk startup cancellation
Stop stale async WebRTC setup after a user cancellation so a late microphone grant does not dereference a nulled peer or continue SDP setup. Fixes #89434
1 parent 1629575 commit 4c55dd8

2 files changed

Lines changed: 151 additions & 24 deletions

File tree

ui/src/ui/chat/realtime-talk-webrtc.ts

Lines changed: 92 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ type ToolBuffer = {
3333
args: string;
3434
};
3535

36+
const cancelledSetup = Symbol("cancelledSetup");
37+
3638
export class WebRtcSdpRealtimeTalkTransport implements RealtimeTalkTransport {
3739
private peer: RTCPeerConnection | null = null;
3840
private channel: RTCDataChannel | null = null;
@@ -58,27 +60,44 @@ export class WebRtcSdpRealtimeTalkTransport implements RealtimeTalkTransport {
5860
throw new Error("Realtime Talk requires browser WebRTC and microphone access");
5961
}
6062
this.closed = false;
61-
this.peer = new RTCPeerConnection();
63+
const peer = new RTCPeerConnection();
64+
this.peer = peer;
6265
this.audio = document.createElement("audio");
6366
this.audio.autoplay = true;
6467
this.audio.style.display = "none";
6568
document.body.append(this.audio);
66-
this.peer.addEventListener("track", (event) => {
69+
peer.addEventListener("track", (event) => {
6770
if (this.audio) {
6871
this.audio.srcObject = event.streams[0];
6972
}
7073
});
71-
this.media = await navigator.mediaDevices.getUserMedia({ audio: true });
72-
for (const track of this.media.getAudioTracks()) {
73-
this.peer.addTrack(track, this.media);
74+
const media = await this.awaitSetupStep(
75+
peer,
76+
navigator.mediaDevices.getUserMedia({ audio: true }),
77+
);
78+
if (media === cancelledSetup) {
79+
return;
80+
}
81+
if (!this.isCurrentPeer(peer)) {
82+
media.getTracks().forEach((track) => track.stop());
83+
return;
84+
}
85+
this.media = media;
86+
for (const track of media.getAudioTracks()) {
87+
peer.addTrack(track, media);
7488
}
75-
this.channel = this.peer.createDataChannel("oai-events");
76-
this.channel.addEventListener("open", () => {
89+
const channel = peer.createDataChannel("oai-events");
90+
if (!this.isCurrentPeer(peer)) {
91+
channel.close();
92+
return;
93+
}
94+
this.channel = channel;
95+
channel.addEventListener("open", () => {
7796
this.ctx.callbacks.onStatus?.("listening");
7897
this.emitTalkEvent({ type: "session.ready" });
7998
});
80-
this.channel.addEventListener("message", (event) => this.handleRealtimeEvent(event.data));
81-
this.peer.addEventListener("connectionstatechange", () => {
99+
channel.addEventListener("message", (event) => this.handleRealtimeEvent(event.data));
100+
peer.addEventListener("connectionstatechange", () => {
82101
if (this.closed) {
83102
return;
84103
}
@@ -87,24 +106,73 @@ export class WebRtcSdpRealtimeTalkTransport implements RealtimeTalkTransport {
87106
}
88107
});
89108

90-
const offer = await this.peer.createOffer();
91-
await this.peer.setLocalDescription(offer);
92-
const sdp = await fetch(this.session.offerUrl ?? "https://api.openai.com/v1/realtime/calls", {
93-
method: "POST",
94-
body: offer.sdp,
95-
headers: {
96-
...this.session.offerHeaders,
97-
Authorization: `Bearer ${this.session.clientSecret}`,
98-
"Content-Type": "application/sdp",
99-
},
100-
});
109+
const offer = await this.awaitSetupStep(peer, peer.createOffer());
110+
if (offer === cancelledSetup) {
111+
return;
112+
}
113+
if (!this.isCurrentPeer(peer)) {
114+
return;
115+
}
116+
const localDescriptionResult = await this.awaitSetupStep(peer, peer.setLocalDescription(offer));
117+
if (localDescriptionResult === cancelledSetup) {
118+
return;
119+
}
120+
if (!this.isCurrentPeer(peer)) {
121+
return;
122+
}
123+
const sdp = await this.awaitSetupStep(
124+
peer,
125+
fetch(this.session.offerUrl ?? "https://api.openai.com/v1/realtime/calls", {
126+
method: "POST",
127+
body: offer.sdp,
128+
headers: {
129+
...this.session.offerHeaders,
130+
Authorization: `Bearer ${this.session.clientSecret}`,
131+
"Content-Type": "application/sdp",
132+
},
133+
}),
134+
);
135+
if (sdp === cancelledSetup) {
136+
return;
137+
}
138+
if (!this.isCurrentPeer(peer)) {
139+
return;
140+
}
101141
if (!sdp.ok) {
102142
throw new Error(`Realtime WebRTC setup failed (${sdp.status})`);
103143
}
104-
await this.peer.setRemoteDescription({
105-
type: "answer",
106-
sdp: await sdp.text(),
107-
});
144+
const answerSdp = await this.awaitSetupStep(peer, sdp.text());
145+
if (answerSdp === cancelledSetup) {
146+
return;
147+
}
148+
if (!this.isCurrentPeer(peer)) {
149+
return;
150+
}
151+
await this.awaitSetupStep(
152+
peer,
153+
peer.setRemoteDescription({
154+
type: "answer",
155+
sdp: answerSdp,
156+
}),
157+
);
158+
}
159+
160+
private isCurrentPeer(peer: RTCPeerConnection): boolean {
161+
return !this.closed && this.peer === peer;
162+
}
163+
164+
private async awaitSetupStep<T>(
165+
peer: RTCPeerConnection,
166+
promise: Promise<T>,
167+
): Promise<T | typeof cancelledSetup> {
168+
try {
169+
return await promise;
170+
} catch (error) {
171+
if (!this.isCurrentPeer(peer)) {
172+
return cancelledSetup;
173+
}
174+
throw error;
175+
}
108176
}
109177

110178
stop(): void {

ui/src/ui/realtime-talk-webrtc.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,65 @@ describe("WebRtcSdpRealtimeTalkTransport", () => {
188188
vi.stubGlobal("RTCPeerConnection", FakePeerConnection as unknown as typeof RTCPeerConnection);
189189
});
190190

191+
it("does not continue WebRTC setup when stopped while microphone access is pending", async () => {
192+
const fetchMock = vi.fn(async () => new Response("answer-sdp"));
193+
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
194+
const stopTrack = vi.fn();
195+
const track = { stop: stopTrack } as unknown as MediaStreamTrack;
196+
const stream = {
197+
getAudioTracks: () => [track],
198+
getTracks: () => [track],
199+
} as unknown as MediaStream;
200+
let resolveMedia: (stream: MediaStream) => void = () => undefined;
201+
Object.defineProperty(globalThis.navigator, "mediaDevices", {
202+
configurable: true,
203+
value: {
204+
getUserMedia: vi.fn(
205+
() =>
206+
new Promise<MediaStream>((resolve) => {
207+
resolveMedia = resolve;
208+
}),
209+
),
210+
},
211+
});
212+
const transport = createOpenAiTransport();
213+
214+
const startPromise = transport.start();
215+
const peer = FakePeerConnection.instances[0];
216+
transport.stop();
217+
resolveMedia(stream);
218+
219+
await expect(startPromise).resolves.toBeUndefined();
220+
expect(peer?.addTrack).not.toHaveBeenCalled();
221+
expect(stopTrack).toHaveBeenCalledTimes(1);
222+
expect(fetchMock).not.toHaveBeenCalled();
223+
});
224+
225+
it("suppresses pending setup errors after stop", async () => {
226+
const fetchMock = vi.fn(async () => new Response("answer-sdp"));
227+
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
228+
let rejectOffer: (error: Error) => void = () => undefined;
229+
const transport = createOpenAiTransport();
230+
231+
const startPromise = transport.start();
232+
const peer = FakePeerConnection.instances[0];
233+
if (!peer) {
234+
throw new Error("expected WebRTC peer");
235+
}
236+
const createOfferSpy = vi.spyOn(peer, "createOffer").mockImplementation(
237+
() =>
238+
new Promise<RTCSessionDescriptionInit>((_, reject) => {
239+
rejectOffer = reject;
240+
}),
241+
);
242+
await vi.waitFor(() => expect(createOfferSpy).toHaveBeenCalled());
243+
transport.stop();
244+
rejectOffer(new Error("closed peer rejected offer creation"));
245+
246+
await expect(startPromise).resolves.toBeUndefined();
247+
expect(fetchMock).not.toHaveBeenCalled();
248+
});
249+
191250
it("sends provider offer headers with the WebRTC SDP request", async () => {
192251
const fetchMock = vi.fn(async () => new Response("answer-sdp"));
193252
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);

0 commit comments

Comments
 (0)