@voicethere/client 0.7.14 → 0.7.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/browser/browser-session.d.ts +1 -1
  3. package/dist/browser/browser-session.d.ts.map +1 -1
  4. package/dist/browser/browser-session.js.map +1 -1
  5. package/dist/browser/browser-voice-session.d.ts +11 -3
  6. package/dist/browser/browser-voice-session.d.ts.map +1 -1
  7. package/dist/browser/browser-voice-session.js +527 -146
  8. package/dist/browser/browser-voice-session.js.map +1 -1
  9. package/dist/browser/client-capabilities.d.ts +5 -0
  10. package/dist/browser/client-capabilities.d.ts.map +1 -1
  11. package/dist/browser/client-capabilities.js +5 -0
  12. package/dist/browser/client-capabilities.js.map +1 -1
  13. package/dist/browser/peer-connection-close.d.ts +14 -2
  14. package/dist/browser/peer-connection-close.d.ts.map +1 -1
  15. package/dist/browser/peer-connection-close.js +40 -6
  16. package/dist/browser/peer-connection-close.js.map +1 -1
  17. package/dist/browser/session-api-retry.d.ts +2 -0
  18. package/dist/browser/session-api-retry.d.ts.map +1 -1
  19. package/dist/browser/session-api-retry.js +37 -3
  20. package/dist/browser/session-api-retry.js.map +1 -1
  21. package/dist/browser/session-provision-poll.d.ts +1 -0
  22. package/dist/browser/session-provision-poll.d.ts.map +1 -1
  23. package/dist/browser/session-provision-poll.js +31 -2
  24. package/dist/browser/session-provision-poll.js.map +1 -1
  25. package/dist/browser/session-provision.d.ts +5 -0
  26. package/dist/browser/session-provision.d.ts.map +1 -1
  27. package/dist/browser/session-provision.js +8 -1
  28. package/dist/browser/session-provision.js.map +1 -1
  29. package/dist/browser/voice-session-diagnostics.d.ts +3 -1
  30. package/dist/browser/voice-session-diagnostics.d.ts.map +1 -1
  31. package/dist/browser/voice-session-diagnostics.js.map +1 -1
  32. package/package.json +1 -1
@@ -4,7 +4,7 @@ import { createLocalSessionError, emitSessionError, isSessionErrorEvent, parseLe
4
4
  import { buildWebRtcConnectionStatus, isWebRtcConnectionReady, resolveReadinessProfile, } from "./webrtc-connection-status.js";
5
5
  import { collectWebRtcDiagnostics, } from "./webrtc-diagnostics.js";
6
6
  import { getDefaultBrowserRuntime, } from "./webrtc-runtime.js";
7
- import { closePeerConnectionAwaitable } from "./peer-connection-close.js";
7
+ import { closePeerConnectionAwaitable, } from "./peer-connection-close.js";
8
8
  import { emitDiagnosticSafely, redactDiagnosticDetail, } from "./voice-session-diagnostics.js";
9
9
  import { waitForIceGatheringComplete } from "./wait-for-ice-gathering.js";
10
10
  import { isWebRtcConnectRetryError, WebRtcConnectRetryError, } from "./webrtc-connect-retry.js";
@@ -107,7 +107,8 @@ export async function connectBrowserVoiceSession(options) {
107
107
  let controlChannel = null;
108
108
  let syncChannel = null;
109
109
  let micStream = null;
110
- const pendingIce = [];
110
+ /** ICE candidates queued by negotiation generation until that PC is ready. */
111
+ const pendingIceByGeneration = new Map();
111
112
  let connectionState = "new";
112
113
  let resolveConnected = null;
113
114
  let rejectConnected = null;
@@ -115,6 +116,63 @@ export async function connectBrowserVoiceSession(options) {
115
116
  let pendingConnectFailure = null;
116
117
  let stopMicPump = null;
117
118
  let gracefulDisconnect = false;
119
+ /** Bumped on each offer / disconnect so stale answer/ICE paths cannot resurrect. */
120
+ let negotiationGeneration = 0;
121
+ /** Serializes overlapping createAnswer/setLocalDescription/gather/send paths. */
122
+ let offerChain = Promise.resolve();
123
+ let disconnectAsyncInFlight = null;
124
+ /** Cached terminal disconnect outcome — repeats must not invent `closed` for a null pc. */
125
+ let terminalDisconnectResult = null;
126
+ /** Generation of the live PC (mirrors offer gen at create); ICE/handlers ignore mismatches. */
127
+ let activePcGeneration = 0;
128
+ /**
129
+ * Signaling/reconnect epoch. Incremented only when starting a reconnect flight
130
+ * or on disconnect (invalidates in-flight WS handlers).
131
+ */
132
+ let signalingEpoch = 0;
133
+ /** True single-flight: concurrent reconnect callers share this promise. */
134
+ let reconnectFlight = null;
135
+ /**
136
+ * After a timed_out/failed replace-close, further PC creation is blocked.
137
+ * The unsafe native PC may still be live — never invent `closed`.
138
+ */
139
+ let replacementBlockedResult = null;
140
+ /** Retained reference after failed replace-close (do not retry native close). */
141
+ let quarantinedPc = null;
142
+ /** Per-PC intentional retire tracking (replaces a global ignore boolean). */
143
+ const intentionallyRetiringPcs = new WeakSet();
144
+ const clearPendingIceGenerations = (keepGeneration) => {
145
+ if (keepGeneration === undefined) {
146
+ pendingIceByGeneration.clear();
147
+ return;
148
+ }
149
+ for (const generation of [...pendingIceByGeneration.keys()]) {
150
+ if (generation !== keepGeneration) {
151
+ pendingIceByGeneration.delete(generation);
152
+ }
153
+ }
154
+ };
155
+ const queuePendingIce = (generation, candidate) => {
156
+ const bucket = pendingIceByGeneration.get(generation);
157
+ if (bucket) {
158
+ bucket.push(candidate);
159
+ }
160
+ else {
161
+ pendingIceByGeneration.set(generation, [candidate]);
162
+ }
163
+ };
164
+ const drainPendingIce = async (targetPc, generation) => {
165
+ const bucket = pendingIceByGeneration.get(generation) ?? [];
166
+ pendingIceByGeneration.delete(generation);
167
+ for (const candidate of bucket) {
168
+ await targetPc.addIceCandidate(candidate).catch(() => undefined);
169
+ }
170
+ };
171
+ const assertReplacementAllowed = () => {
172
+ if (replacementBlockedResult) {
173
+ throw new Error(`peer replacement blocked: previous close ${replacementBlockedResult.status}`);
174
+ }
175
+ };
118
176
  const reconnectPolicy = options.reconnectPolicy ?? "same-session";
119
177
  const maxAutoReconnectAttempts = options.maxAutoReconnectAttempts ?? 4;
120
178
  let autoReconnectAttempts = 0;
@@ -150,6 +208,8 @@ export async function connectBrowserVoiceSession(options) {
150
208
  return;
151
209
  pendingConnectFailure = null;
152
210
  resolveConnected?.();
211
+ // Success path must drop waiter handles immediately (not only timeout/reject).
212
+ clearConnectedWait();
153
213
  };
154
214
  const updateConnectionSnapshot = (patch) => {
155
215
  Object.assign(connectionSnapshot, patch);
@@ -272,9 +332,11 @@ export async function connectBrowserVoiceSession(options) {
272
332
  debug?.warn("dc", "malformed", raw);
273
333
  }
274
334
  };
275
- const wireBinaryChannel = (channel, kind) => {
335
+ const wireBinaryChannel = (channel, kind, isChannelCurrent) => {
276
336
  channel.binaryType = "arraybuffer";
277
337
  channel.onmessage = (event) => {
338
+ if (!isChannelCurrent())
339
+ return;
278
340
  if (typeof event.data === "string") {
279
341
  if (kind === "control") {
280
342
  handleControlJson(String(event.data));
@@ -304,21 +366,24 @@ export async function connectBrowserVoiceSession(options) {
304
366
  syncOutboundAudioTrack();
305
367
  debug?.info("voice", "mic_granted");
306
368
  }
307
- let ignorePeerConnectionClose = false;
308
- const resetPeerConnection = (options) => {
369
+ /**
370
+ * Retire the current PC with the awaitable close barrier.
371
+ * Callers that will create a replacement must abort when status is not `closed`.
372
+ * On timed_out/failed, quarantine and block all future replacements.
373
+ */
374
+ const retirePeerConnection = async (retireOptions) => {
375
+ if (replacementBlockedResult) {
376
+ return replacementBlockedResult;
377
+ }
309
378
  stopMicPump?.();
310
379
  stopMicPump = null;
311
380
  controlChannel = null;
312
381
  syncChannel = null;
313
- if (pc) {
314
- ignorePeerConnectionClose = true;
315
- pc.close();
316
- ignorePeerConnectionClose = false;
317
- }
318
- pc = null;
319
- pendingIce.length = 0;
382
+ const localPc = pc;
383
+ activePcGeneration = 0;
384
+ clearPendingIceGenerations();
320
385
  connectionState = "new";
321
- if (!options?.preserveConnectedWait) {
386
+ if (!retireOptions?.preserveConnectedWait) {
322
387
  connectedPromise = null;
323
388
  resolveConnected = null;
324
389
  rejectConnected = null;
@@ -331,13 +396,38 @@ export async function connectBrowserVoiceSession(options) {
331
396
  controlChannelOpen: false,
332
397
  syncChannelOpen: false,
333
398
  });
399
+ if (!localPc) {
400
+ return {
401
+ status: "closed",
402
+ mode: "sync",
403
+ durationMs: 0,
404
+ timedOut: false,
405
+ };
406
+ }
407
+ intentionallyRetiringPcs.add(localPc);
408
+ // Detach from live slot before awaiting close so handlers see identity change.
409
+ pc = null;
410
+ const closeResult = await closePeerConnectionAwaitable(localPc);
411
+ if (closeResult.status !== "closed") {
412
+ // Never invent closed later — retain unsafe PC, block replacement forever.
413
+ replacementBlockedResult = closeResult;
414
+ quarantinedPc = localPc;
415
+ terminalDisconnectResult = closeResult;
416
+ return closeResult;
417
+ }
418
+ return closeResult;
334
419
  };
335
- const bindDataChannel = (channel, binding) => {
420
+ const bindDataChannel = (channel, binding, isPcCurrent) => {
336
421
  binding.assign(channel);
422
+ const isChannelCurrent = () => isPcCurrent() && binding.getAssigned() === channel;
337
423
  const markOpen = () => {
424
+ if (!isChannelCurrent())
425
+ return;
338
426
  updateConnectionSnapshot({ [binding.openField]: true });
339
427
  };
340
428
  channel.onopen = () => {
429
+ if (!isChannelCurrent())
430
+ return;
341
431
  debug?.info("dc", "open", binding.label);
342
432
  markOpen();
343
433
  binding.onOpen?.(channel);
@@ -345,17 +435,25 @@ export async function connectBrowserVoiceSession(options) {
345
435
  if (channel.readyState === "open")
346
436
  markOpen();
347
437
  channel.onclose = () => {
438
+ if (!isChannelCurrent())
439
+ return;
348
440
  debug?.info("dc", "close", binding.label);
349
441
  binding.assign(null);
350
442
  updateConnectionSnapshot({ [binding.openField]: false });
351
443
  };
352
- wireBinaryChannel(channel, binding.kind);
444
+ channel.onerror = () => {
445
+ if (!isChannelCurrent())
446
+ return;
447
+ debug?.warn("dc", "error", binding.label);
448
+ };
449
+ wireBinaryChannel(channel, binding.kind, isChannelCurrent);
353
450
  };
354
- const wireControl = (channel) => {
451
+ const wireControl = (channel, isPcCurrent) => {
355
452
  bindDataChannel(channel, {
356
453
  kind: "control",
357
454
  label: VOICE_CONTROL_CHANNEL_LABEL,
358
455
  openField: "controlChannelOpen",
456
+ getAssigned: () => controlChannel,
359
457
  assign: (next) => {
360
458
  controlChannel = next;
361
459
  },
@@ -367,53 +465,74 @@ export async function connectBrowserVoiceSession(options) {
367
465
  customer_context: options.customerContext,
368
466
  }));
369
467
  },
370
- });
468
+ }, isPcCurrent);
371
469
  };
372
- const wireSync = (channel) => {
470
+ const wireSync = (channel, isPcCurrent) => {
373
471
  bindDataChannel(channel, {
374
472
  kind: "sync",
375
473
  label: VOICE_SYNC_CHANNEL_LABEL,
376
474
  openField: "syncChannelOpen",
475
+ getAssigned: () => syncChannel,
377
476
  assign: (next) => {
378
477
  syncChannel = next;
379
478
  },
380
- });
479
+ }, isPcCurrent);
381
480
  };
382
- const onServerOffer = async (sdp) => {
481
+ const onServerOffer = async (sdp, offerGeneration) => {
383
482
  let step = "reset_peer_connection";
384
483
  const startedAtMs = Date.now();
385
484
  const logOfferStep = (name) => {
386
485
  debug?.info("signaling", "offer_step", `${name} elapsed_ms=${Date.now() - startedAtMs}`);
387
486
  };
487
+ const isOfferCurrent = () => offerGeneration === negotiationGeneration && !gracefulDisconnect;
388
488
  try {
489
+ if (!isOfferCurrent())
490
+ return;
491
+ assertReplacementAllowed();
389
492
  if (pc) {
390
- ignorePeerConnectionClose = true;
391
- pc.close();
392
- ignorePeerConnectionClose = false;
393
- pc = null;
394
- pendingIce.length = 0;
395
- controlChannel = null;
396
- syncChannel = null;
397
- connectionState = "new";
398
- updateConnectionSnapshot({
399
- peerConnectionState: "new",
400
- inboundAudioTrack: false,
401
- outboundAudioTrack: false,
402
- controlChannelOpen: false,
403
- syncChannelOpen: false,
493
+ step = "await_previous_peer_close";
494
+ const closeResult = await retirePeerConnection({
495
+ preserveConnectedWait: true,
404
496
  });
497
+ emitDiagnosticSafely(options.onDiagnosticEvent, {
498
+ type: "peer_close",
499
+ status: closeResult.status,
500
+ mode: closeResult.mode,
501
+ durationMs: closeResult.durationMs,
502
+ timedOut: closeResult.timedOut,
503
+ context: "offer_replace",
504
+ ...(closeResult.error !== undefined
505
+ ? { error: redactDiagnosticDetail(closeResult.error) }
506
+ : {}),
507
+ });
508
+ if (!isOfferCurrent())
509
+ return;
510
+ if (closeResult.status !== "closed") {
511
+ throw new Error(`cannot replace peer connection: previous close ${closeResult.status}`);
512
+ }
405
513
  }
406
514
  logOfferStep(step);
515
+ assertReplacementAllowed();
407
516
  step = "create_peer_connection";
408
517
  ensureConnectedPromise();
409
- pc = new runtime.RTCPeerConnection({
518
+ const localPc = new runtime.RTCPeerConnection({
410
519
  iceServers,
411
520
  ...(options.iceTransportPolicy
412
521
  ? { iceTransportPolicy: options.iceTransportPolicy }
413
522
  : {}),
414
523
  });
524
+ pc = localPc;
525
+ activePcGeneration = offerGeneration;
526
+ const boundGeneration = offerGeneration;
527
+ const isPcCurrent = () => pc === localPc &&
528
+ activePcGeneration === boundGeneration &&
529
+ offerGeneration === negotiationGeneration &&
530
+ !gracefulDisconnect &&
531
+ !replacementBlockedResult;
415
532
  logOfferStep(step);
416
- pc.ontrack = (event) => {
533
+ localPc.ontrack = (event) => {
534
+ if (!isPcCurrent())
535
+ return;
417
536
  if (event.track.kind !== "audio")
418
537
  return;
419
538
  const stream = event.streams[0] ?? new MediaStream([event.track]);
@@ -425,15 +544,19 @@ export async function connectBrowserVoiceSession(options) {
425
544
  updateConnectionSnapshot({ inboundAudioTrack: true });
426
545
  debug?.info("webrtc", "agent_audio_track");
427
546
  };
428
- pc.ondatachannel = (event) => {
547
+ localPc.ondatachannel = (event) => {
548
+ if (!isPcCurrent())
549
+ return;
429
550
  if (event.channel.label === VOICE_CONTROL_CHANNEL_LABEL) {
430
- wireControl(event.channel);
551
+ wireControl(event.channel, isPcCurrent);
431
552
  }
432
553
  else if (event.channel.label === VOICE_SYNC_CHANNEL_LABEL) {
433
- wireSync(event.channel);
554
+ wireSync(event.channel, isPcCurrent);
434
555
  }
435
556
  };
436
- pc.onicecandidate = (event) => {
557
+ localPc.onicecandidate = (event) => {
558
+ if (!isPcCurrent())
559
+ return;
437
560
  if (event.candidate) {
438
561
  sendToServer({
439
562
  type: "ice-candidate",
@@ -442,41 +565,60 @@ export async function connectBrowserVoiceSession(options) {
442
565
  });
443
566
  }
444
567
  };
445
- pc.onconnectionstatechange = () => {
446
- connectionState = pc?.connectionState ?? "new";
568
+ localPc.onconnectionstatechange = () => {
569
+ const intentionalRetire = intentionallyRetiringPcs.has(localPc);
570
+ // Per-PC identity: never let a retired/stale PC mutate live globals.
571
+ if (pc !== localPc) {
572
+ return;
573
+ }
574
+ if (!isPcCurrent() && !intentionalRetire)
575
+ return;
576
+ connectionState = localPc.connectionState ?? "new";
447
577
  debug?.info("webrtc", "connection_state", connectionState);
448
578
  updateConnectionSnapshot({ peerConnectionState: connectionState });
449
579
  if (connectionState === "connected") {
580
+ if (!isPcCurrent())
581
+ return;
450
582
  autoReconnectAttempts = 0;
451
583
  stopMicPump?.();
452
584
  stopMicPump = null;
453
585
  if (micStream && (options.micPump ?? "silent") === "silent") {
454
- stopMicPump = createMicPump(micStream, () => pc?.connectionState === "connected", debug);
586
+ stopMicPump = createMicPump(micStream, () => pc === localPc && localPc.connectionState === "connected", debug);
455
587
  }
456
588
  syncOutboundAudioTrack();
457
589
  }
458
590
  else if (connectionState === "failed") {
591
+ if (!isPcCurrent())
592
+ return;
459
593
  handleTransportFailure("failed", "webrtc_failed");
460
594
  }
461
595
  else if (connectionState === "closed") {
462
- if (ignorePeerConnectionClose || gracefulDisconnect) {
596
+ if (intentionalRetire || gracefulDisconnect) {
463
597
  if (gracefulDisconnect) {
464
598
  rejectConnectedWait(new Error(`peer connection ${connectionState}`), false);
465
599
  }
466
600
  return;
467
601
  }
602
+ if (!isPcCurrent())
603
+ return;
468
604
  handleTransportFailure("closed", "webrtc_closed");
469
605
  }
470
606
  };
471
- pc.oniceconnectionstatechange = () => {
472
- debug?.info("webrtc", "ice_connection_state", pc?.iceConnectionState ?? "unknown");
607
+ localPc.oniceconnectionstatechange = () => {
608
+ if (!isPcCurrent())
609
+ return;
610
+ debug?.info("webrtc", "ice_connection_state", localPc.iceConnectionState ?? "unknown");
473
611
  };
474
- pc.onicegatheringstatechange = () => {
475
- debug?.info("webrtc", "ice_gathering_state", pc?.iceGatheringState ?? "unknown");
612
+ localPc.onicegatheringstatechange = () => {
613
+ if (!isPcCurrent())
614
+ return;
615
+ debug?.info("webrtc", "ice_gathering_state", localPc.iceGatheringState ?? "unknown");
476
616
  };
477
617
  if (micStream) {
478
618
  step = "attach_mic";
479
- await attachMicTracks(pc, micStream);
619
+ await attachMicTracks(localPc, micStream);
620
+ if (!isPcCurrent())
621
+ return;
480
622
  syncOutboundAudioTrack();
481
623
  if ((options.micPump ?? "silent") === "external") {
482
624
  for (const track of micStream.getAudioTracks()) {
@@ -491,35 +633,49 @@ export async function connectBrowserVoiceSession(options) {
491
633
  logOfferStep(step);
492
634
  }
493
635
  step = "set_remote_description";
636
+ if (!isOfferCurrent() || !isPcCurrent())
637
+ return;
494
638
  if (!remoteOfferHasIceUfrag(sdp)) {
495
639
  throw new Error("set_remote_description called with no ice-ufrag (remote offer missing a=ice-ufrag)");
496
640
  }
497
- await pc.setRemoteDescription(sdp);
641
+ await localPc.setRemoteDescription(sdp);
642
+ if (!isOfferCurrent() || !isPcCurrent())
643
+ return;
498
644
  logOfferStep(step);
499
645
  step = "drain_pending_ice";
500
- for (const candidate of pendingIce.splice(0)) {
501
- await pc.addIceCandidate(candidate);
502
- }
646
+ if (!isOfferCurrent() || !isPcCurrent())
647
+ return;
648
+ await drainPendingIce(localPc, offerGeneration);
503
649
  logOfferStep(step);
504
650
  step = "create_answer";
505
- const answer = await pc.createAnswer();
651
+ if (!isOfferCurrent() || !isPcCurrent())
652
+ return;
653
+ const answer = await localPc.createAnswer();
654
+ if (!isOfferCurrent() || !isPcCurrent())
655
+ return;
506
656
  logOfferStep(step);
507
657
  step = "set_local_description";
508
- await pc.setLocalDescription(answer);
658
+ await localPc.setLocalDescription(answer);
659
+ if (!isOfferCurrent() || !isPcCurrent())
660
+ return;
509
661
  logOfferStep(step);
510
662
  step = "wait_ice_gathering";
511
- await waitForIceGatheringComplete(pc);
663
+ await waitForIceGatheringComplete(localPc);
664
+ if (!isOfferCurrent() || !isPcCurrent())
665
+ return;
512
666
  logOfferStep(step);
513
667
  step = "send_answer";
514
668
  sendToServer({
515
669
  type: "answer",
516
670
  targetPeerId: VOICE_AGENT_SERVER_PEER_ID,
517
- sdp: pc.localDescription,
671
+ sdp: localPc.localDescription,
518
672
  });
519
673
  debug?.info("signaling", "answer_sent");
520
674
  logOfferStep(step);
521
675
  }
522
676
  catch (error) {
677
+ if (!isOfferCurrent())
678
+ return;
523
679
  const detail = error instanceof Error ? error.message : String(error);
524
680
  const message = `WebRTC offer handler failed at ${step}: ${detail}`;
525
681
  debug?.error("signaling", "offer_handler_failed", `${message} elapsed_ms=${Date.now() - startedAtMs}`);
@@ -539,6 +695,20 @@ export async function connectBrowserVoiceSession(options) {
539
695
  }
540
696
  }
541
697
  };
698
+ /** One active negotiation at a time; newer offers supersede older generations. */
699
+ const enqueueServerOffer = (sdp) => {
700
+ if (replacementBlockedResult) {
701
+ debug?.warn("signaling", "offer_ignored_replacement_blocked", replacementBlockedResult.status);
702
+ return;
703
+ }
704
+ const offerGeneration = ++negotiationGeneration;
705
+ // Drop ICE buckets from prior generations; keep current gen for early candidates.
706
+ clearPendingIceGenerations(offerGeneration);
707
+ offerChain = offerChain
708
+ .catch(() => undefined)
709
+ .then(() => onServerOffer(sdp, offerGeneration));
710
+ void offerChain.catch(() => undefined);
711
+ };
542
712
  const scheduleAutoReconnect = (reason) => {
543
713
  if (gracefulDisconnect || reconnectPolicy === "new-session")
544
714
  return;
@@ -558,27 +728,44 @@ export async function connectBrowserVoiceSession(options) {
558
728
  });
559
729
  }, delayMs);
560
730
  };
561
- const attachWsMessageHandler = () => {
562
- if (!ws)
563
- return;
564
- ws.onmessage = (event) => {
731
+ const attachWsHandlers = (localWs, boundEpoch) => {
732
+ const isCurrentWs = () => ws === localWs && boundEpoch === signalingEpoch && !gracefulDisconnect;
733
+ localWs.onmessage = (event) => {
734
+ if (!isCurrentWs())
735
+ return;
565
736
  const message = JSON.parse(String(event.data));
566
737
  debug?.debug("signaling", message.type, message.peerId);
567
738
  switch (message.type) {
568
739
  case "offer":
569
740
  if (message.peerId === VOICE_AGENT_SERVER_PEER_ID && message.sdp) {
570
- void onServerOffer(message.sdp);
741
+ enqueueServerOffer(message.sdp);
571
742
  }
572
743
  break;
573
744
  case "ice-candidate":
574
745
  if (message.peerId === VOICE_AGENT_SERVER_PEER_ID &&
575
746
  message.candidate &&
576
- pc) {
577
- if (!pc.remoteDescription) {
578
- pendingIce.push(message.candidate);
747
+ !gracefulDisconnect) {
748
+ // Attribute to the latest negotiation generation (offer may already
749
+ // be enqueued while PC is still null on the offerChain).
750
+ const iceGeneration = negotiationGeneration;
751
+ if (iceGeneration === 0)
752
+ break;
753
+ const targetPc = pc;
754
+ if (targetPc &&
755
+ activePcGeneration === iceGeneration &&
756
+ pc === targetPc) {
757
+ if (!targetPc.remoteDescription) {
758
+ queuePendingIce(iceGeneration, message.candidate);
759
+ }
760
+ else {
761
+ void targetPc.addIceCandidate(message.candidate).catch(() => {
762
+ /* ignore stale/failed ICE on captured PC */
763
+ });
764
+ }
579
765
  }
580
766
  else {
581
- void pc.addIceCandidate(message.candidate);
767
+ // PC not yet materialized for this generation — queue for drain.
768
+ queuePendingIce(iceGeneration, message.candidate);
582
769
  }
583
770
  }
584
771
  break;
@@ -586,28 +773,86 @@ export async function connectBrowserVoiceSession(options) {
586
773
  break;
587
774
  }
588
775
  };
776
+ localWs.onclose = () => {
777
+ if (!isCurrentWs())
778
+ return;
779
+ if (gracefulDisconnect || reconnectPolicy === "new-session")
780
+ return;
781
+ scheduleAutoReconnect("signaling_closed");
782
+ };
589
783
  };
590
- const joinSignalingRoom = async (isReconnect) => {
784
+ const joinSignalingRoom = async (isReconnect, boundEpoch) => {
591
785
  if (isReconnect) {
592
- resetPeerConnection({ preserveConnectedWait: true });
593
- if (ws) {
594
- ws.onclose = null;
595
- ws.close();
596
- ws = null;
786
+ assertReplacementAllowed();
787
+ const closeResult = await retirePeerConnection({
788
+ preserveConnectedWait: true,
789
+ });
790
+ emitDiagnosticSafely(options.onDiagnosticEvent, {
791
+ type: "peer_close",
792
+ status: closeResult.status,
793
+ mode: closeResult.mode,
794
+ durationMs: closeResult.durationMs,
795
+ timedOut: closeResult.timedOut,
796
+ context: "reconnect",
797
+ ...(closeResult.error !== undefined
798
+ ? { error: redactDiagnosticDetail(closeResult.error) }
799
+ : {}),
800
+ });
801
+ if (closeResult.status !== "closed") {
802
+ throw new Error(`reconnect blocked: peer close ${closeResult.status}`);
597
803
  }
804
+ if (boundEpoch !== signalingEpoch) {
805
+ return;
806
+ }
807
+ const previousWs = ws;
808
+ if (previousWs) {
809
+ previousWs.onclose = null;
810
+ previousWs.onmessage = null;
811
+ previousWs.onerror = null;
812
+ try {
813
+ previousWs.close();
814
+ }
815
+ catch {
816
+ /* ignore */
817
+ }
818
+ if (ws === previousWs) {
819
+ ws = null;
820
+ }
821
+ }
822
+ }
823
+ if (boundEpoch !== signalingEpoch) {
824
+ return;
598
825
  }
599
- ws = new runtime.WebSocket(signalingUrl);
826
+ assertReplacementAllowed();
827
+ const nextWs = new runtime.WebSocket(signalingUrl);
828
+ if (boundEpoch !== signalingEpoch) {
829
+ try {
830
+ nextWs.close();
831
+ }
832
+ catch {
833
+ /* ignore */
834
+ }
835
+ return;
836
+ }
837
+ ws = nextWs;
600
838
  await new Promise((resolve, reject) => {
601
- if (!ws)
602
- return reject(new Error("WebSocket missing"));
603
- ws.onopen = () => {
839
+ nextWs.onopen = () => {
840
+ if (ws !== nextWs || boundEpoch !== signalingEpoch) {
841
+ reject(new Error("WebSocket superseded during reconnect"));
842
+ return;
843
+ }
844
+ // sendSignal uses global ws — only send when this socket is current.
604
845
  sendSignal({ type: "join", room: roomId, peerId });
605
846
  debug?.info("signaling", "join_sent", `room=${roomId} peer=${peerId}`);
606
847
  debug?.info("signaling", isReconnect ? "rejoined" : "joined", roomId);
607
848
  updateConnectionSnapshot({ signalingJoined: true });
608
849
  resolve();
609
850
  };
610
- ws.onerror = () => {
851
+ nextWs.onerror = () => {
852
+ if (ws !== nextWs || boundEpoch !== signalingEpoch) {
853
+ reject(new Error("WebSocket superseded during reconnect"));
854
+ return;
855
+ }
611
856
  dispatchConnectionError(createConnectionError("WebSocket error", {
612
857
  subsystem: "webrtc",
613
858
  sessionId: orchestratorSessionId,
@@ -618,20 +863,74 @@ export async function connectBrowserVoiceSession(options) {
618
863
  reject(new Error("WebSocket error"));
619
864
  };
620
865
  });
621
- attachWsMessageHandler();
622
- ws.onclose = () => {
623
- if (gracefulDisconnect || reconnectPolicy === "new-session")
624
- return;
625
- scheduleAutoReconnect("signaling_closed");
626
- };
866
+ if (boundEpoch !== signalingEpoch) {
867
+ if (ws === nextWs) {
868
+ nextWs.onclose = null;
869
+ nextWs.onmessage = null;
870
+ try {
871
+ nextWs.close();
872
+ }
873
+ catch {
874
+ /* ignore */
875
+ }
876
+ ws = null;
877
+ }
878
+ return;
879
+ }
880
+ attachWsHandlers(nextWs, boundEpoch);
627
881
  if (isReconnect) {
628
882
  debug?.info("session", "same_session_reconnect", orchestratorSessionId);
629
883
  }
630
884
  };
631
- const reconnectSignaling = async () => {
632
- await joinSignalingRoom(true);
885
+ const reconnectSignaling = () => {
886
+ if (gracefulDisconnect) {
887
+ return Promise.resolve();
888
+ }
889
+ if (replacementBlockedResult) {
890
+ return Promise.reject(new Error(`reconnect blocked: previous close ${replacementBlockedResult.status}`));
891
+ }
892
+ // True single-flight: all concurrent callers share one flight.
893
+ if (reconnectFlight) {
894
+ return reconnectFlight;
895
+ }
896
+ const epoch = ++signalingEpoch;
897
+ const flight = joinSignalingRoom(true, epoch).finally(() => {
898
+ if (reconnectFlight === flight) {
899
+ reconnectFlight = null;
900
+ }
901
+ });
902
+ reconnectFlight = flight;
903
+ return flight;
904
+ };
905
+ const cleanupFailedInitialJoin = () => {
906
+ const localWs = ws;
907
+ ws = null;
908
+ if (localWs) {
909
+ localWs.onclose = null;
910
+ localWs.onmessage = null;
911
+ localWs.onerror = null;
912
+ try {
913
+ localWs.close();
914
+ }
915
+ catch {
916
+ /* ignore */
917
+ }
918
+ }
919
+ const localMic = micStream;
920
+ micStream = null;
921
+ localMic?.getTracks().forEach((track) => track.stop());
922
+ updateConnectionSnapshot({
923
+ signalingJoined: false,
924
+ outboundAudioTrack: false,
925
+ });
633
926
  };
634
- await joinSignalingRoom(false);
927
+ try {
928
+ await joinSignalingRoom(false, signalingEpoch);
929
+ }
930
+ catch (error) {
931
+ cleanupFailedInitialJoin();
932
+ throw error;
933
+ }
635
934
  publishConnectionStatus();
636
935
  const requireOpenControl = () => {
637
936
  if (!controlChannel || controlChannel.readyState !== "open") {
@@ -664,21 +963,27 @@ export async function connectBrowserVoiceSession(options) {
664
963
  throw error;
665
964
  };
666
965
  while (true) {
667
- if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile))
966
+ if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile)) {
967
+ clearConnectedWait();
668
968
  return;
969
+ }
669
970
  if (pendingConnectFailure) {
670
971
  throw pendingConnectFailure;
671
972
  }
973
+ if (gracefulDisconnect) {
974
+ throw new Error("disconnected");
975
+ }
672
976
  const remainingMs = deadlineMs - Date.now();
673
977
  if (remainingMs <= 0) {
674
978
  throwConnectTimeout();
675
979
  }
676
980
  const waitPromise = ensureConnectedPromise();
981
+ let timer;
677
982
  try {
678
983
  await Promise.race([
679
984
  waitPromise,
680
985
  new Promise((_, reject) => {
681
- setTimeout(() => {
986
+ timer = setTimeout(() => {
682
987
  reject(new Error("__wait_for_connected_timeout__"));
683
988
  }, remainingMs);
684
989
  }),
@@ -695,8 +1000,15 @@ export async function connectBrowserVoiceSession(options) {
695
1000
  }
696
1001
  throw error;
697
1002
  }
698
- if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile))
1003
+ finally {
1004
+ // Success, retry, and disconnect paths must not leave a live timer.
1005
+ if (timer)
1006
+ clearTimeout(timer);
1007
+ }
1008
+ if (isWebRtcConnectionReady(connectionSnapshot, readinessProfile)) {
1009
+ clearConnectedWait();
699
1010
  return;
1011
+ }
700
1012
  }
701
1013
  };
702
1014
  return {
@@ -750,19 +1062,30 @@ export async function connectBrowserVoiceSession(options) {
750
1062
  disconnect: () => {
751
1063
  // Sync terminal invalidation — must not await native close (reconnect opens WS promptly).
752
1064
  gracefulDisconnect = true;
1065
+ negotiationGeneration += 1;
1066
+ activePcGeneration = 0;
1067
+ clearPendingIceGenerations();
1068
+ // Invalidate in-flight reconnect WS handlers / epoch.
1069
+ signalingEpoch += 1;
753
1070
  if (reconnectTimer)
754
1071
  clearTimeout(reconnectTimer);
755
1072
  reconnectTimer = undefined;
756
1073
  stopMicPump?.();
757
1074
  stopMicPump = null;
1075
+ rejectConnected?.(new Error("disconnected"));
1076
+ clearConnectedWait();
1077
+ pendingConnectFailure = null;
1078
+ const asyncOwnsPeerClose = disconnectAsyncInFlight !== null;
758
1079
  const localControl = controlChannel;
759
1080
  const localSync = syncChannel;
760
- const localPc = pc;
1081
+ const localPc = asyncOwnsPeerClose ? null : pc;
761
1082
  const localWs = ws;
762
1083
  const localMic = micStream;
763
1084
  controlChannel = null;
764
1085
  syncChannel = null;
765
- pc = null;
1086
+ if (!asyncOwnsPeerClose) {
1087
+ pc = null;
1088
+ }
766
1089
  ws = null;
767
1090
  micStream = null;
768
1091
  try {
@@ -788,11 +1111,28 @@ export async function connectBrowserVoiceSession(options) {
788
1111
  /* ignore */
789
1112
  }
790
1113
  }
791
- try {
792
- localPc?.close();
1114
+ // Do not sync-close while disconnectAsync owns native close — that masks
1115
+ // timed_out/failed outcomes from soak callers awaiting the async barrier.
1116
+ // Also do not sync-close a quarantined PC (native close already timed out).
1117
+ if (localPc && localPc !== quarantinedPc) {
1118
+ intentionallyRetiringPcs.add(localPc);
1119
+ try {
1120
+ localPc.close();
1121
+ }
1122
+ catch {
1123
+ /* ignore */
1124
+ }
793
1125
  }
794
- catch {
795
- /* ignore */
1126
+ // If sync disconnect wins on an async-capable runtime, record an explicit
1127
+ // sync terminal outcome — later disconnectAsync must not claim async close.
1128
+ // Prefer retained replacement-blocked failure over inventing closed.
1129
+ if (!asyncOwnsPeerClose && !terminalDisconnectResult) {
1130
+ terminalDisconnectResult = replacementBlockedResult ?? {
1131
+ status: "closed",
1132
+ mode: "sync",
1133
+ durationMs: 0,
1134
+ timedOut: false,
1135
+ };
796
1136
  }
797
1137
  localMic?.getTracks().forEach((track) => track.stop());
798
1138
  updateConnectionSnapshot({
@@ -806,66 +1146,107 @@ export async function connectBrowserVoiceSession(options) {
806
1146
  debug?.info("session", "disconnected");
807
1147
  },
808
1148
  disconnectAsync: async () => {
809
- gracefulDisconnect = true;
810
- if (reconnectTimer)
811
- clearTimeout(reconnectTimer);
812
- reconnectTimer = undefined;
813
- stopMicPump?.();
814
- stopMicPump = null;
815
- const localControl = controlChannel;
816
- const localSync = syncChannel;
817
- const localPc = pc;
818
- const localWs = ws;
819
- const localMic = micStream;
820
- controlChannel = null;
821
- syncChannel = null;
822
- pc = null;
823
- ws = null;
824
- micStream = null;
825
- try {
826
- localControl?.close();
1149
+ if (terminalDisconnectResult) {
1150
+ return terminalDisconnectResult;
827
1151
  }
828
- catch {
829
- /* ignore */
1152
+ if (replacementBlockedResult) {
1153
+ // Unsafe old PC already retired/quarantined — surface that strict failure.
1154
+ terminalDisconnectResult = replacementBlockedResult;
1155
+ return replacementBlockedResult;
830
1156
  }
831
- try {
832
- localSync?.close();
833
- }
834
- catch {
835
- /* ignore */
1157
+ if (disconnectAsyncInFlight) {
1158
+ return disconnectAsyncInFlight;
836
1159
  }
837
- if (localWs) {
838
- localWs.onclose = null;
839
- localWs.onmessage = null;
840
- localWs.onerror = null;
1160
+ disconnectAsyncInFlight = (async () => {
1161
+ gracefulDisconnect = true;
1162
+ negotiationGeneration += 1;
1163
+ activePcGeneration = 0;
1164
+ clearPendingIceGenerations();
1165
+ signalingEpoch += 1;
1166
+ if (reconnectTimer)
1167
+ clearTimeout(reconnectTimer);
1168
+ reconnectTimer = undefined;
1169
+ stopMicPump?.();
1170
+ stopMicPump = null;
1171
+ rejectConnected?.(new Error("disconnected"));
1172
+ clearConnectedWait();
1173
+ pendingConnectFailure = null;
1174
+ const localControl = controlChannel;
1175
+ const localSync = syncChannel;
1176
+ const localPc = pc;
1177
+ const localWs = ws;
1178
+ const localMic = micStream;
1179
+ controlChannel = null;
1180
+ syncChannel = null;
1181
+ pc = null;
1182
+ ws = null;
1183
+ micStream = null;
841
1184
  try {
842
- localWs.close();
1185
+ localControl?.close();
1186
+ }
1187
+ catch {
1188
+ /* ignore */
1189
+ }
1190
+ try {
1191
+ localSync?.close();
843
1192
  }
844
1193
  catch {
845
1194
  /* ignore */
846
1195
  }
1196
+ if (localWs) {
1197
+ localWs.onclose = null;
1198
+ localWs.onmessage = null;
1199
+ localWs.onerror = null;
1200
+ try {
1201
+ localWs.close();
1202
+ }
1203
+ catch {
1204
+ /* ignore */
1205
+ }
1206
+ }
1207
+ localMic?.getTracks().forEach((track) => track.stop());
1208
+ // Never retry native close on a quarantined PC after timed_out/failed.
1209
+ const closeTarget = localPc && localPc !== quarantinedPc ? localPc : null;
1210
+ if (closeTarget) {
1211
+ intentionallyRetiringPcs.add(closeTarget);
1212
+ }
1213
+ const closeResult = closeTarget
1214
+ ? await closePeerConnectionAwaitable(closeTarget)
1215
+ : (replacementBlockedResult ?? {
1216
+ status: "closed",
1217
+ mode: "sync",
1218
+ durationMs: 0,
1219
+ timedOut: false,
1220
+ });
1221
+ terminalDisconnectResult = closeResult;
1222
+ emitDiagnosticSafely(options.onDiagnosticEvent, {
1223
+ type: "peer_close",
1224
+ status: closeResult.status,
1225
+ mode: closeResult.mode,
1226
+ durationMs: closeResult.durationMs,
1227
+ timedOut: closeResult.timedOut,
1228
+ context: "disconnect",
1229
+ ...(closeResult.error !== undefined
1230
+ ? { error: redactDiagnosticDetail(closeResult.error) }
1231
+ : {}),
1232
+ });
1233
+ updateConnectionSnapshot({
1234
+ signalingJoined: false,
1235
+ peerConnectionState: "closed",
1236
+ inboundAudioTrack: false,
1237
+ outboundAudioTrack: false,
1238
+ controlChannelOpen: false,
1239
+ syncChannelOpen: false,
1240
+ });
1241
+ debug?.info("session", "disconnected", `peer_close=${closeResult.status}`);
1242
+ return closeResult;
1243
+ })();
1244
+ try {
1245
+ return await disconnectAsyncInFlight;
1246
+ }
1247
+ finally {
1248
+ disconnectAsyncInFlight = null;
847
1249
  }
848
- localMic?.getTracks().forEach((track) => track.stop());
849
- const closeResult = await closePeerConnectionAwaitable(localPc);
850
- emitDiagnosticSafely(options.onDiagnosticEvent, {
851
- type: "peer_close",
852
- mode: closeResult.mode,
853
- durationMs: closeResult.durationMs,
854
- timedOut: closeResult.timedOut,
855
- context: "disconnect",
856
- ...(closeResult.error !== undefined
857
- ? { error: redactDiagnosticDetail(closeResult.error) }
858
- : {}),
859
- });
860
- updateConnectionSnapshot({
861
- signalingJoined: false,
862
- peerConnectionState: "closed",
863
- inboundAudioTrack: false,
864
- outboundAudioTrack: false,
865
- controlChannelOpen: false,
866
- syncChannelOpen: false,
867
- });
868
- debug?.info("session", "disconnected");
869
1250
  },
870
1251
  };
871
1252
  }